├── rootfs ├── run │ └── .placeholder ├── usr │ ├── share │ │ ├── theme │ │ │ └── themes │ │ │ │ ├── default │ │ │ │ ├── eye-sore │ │ │ │ ├── hacker │ │ │ │ ├── white-on-black │ │ │ │ └── blue │ │ ├── cowsay │ │ │ ├── licenses │ │ │ └── cows │ │ │ │ ├── ferris │ │ │ │ └── cow │ │ └── games │ │ │ └── fortunes │ │ │ ├── risque │ │ │ └── fortunes │ └── bin │ │ ├── [ │ │ ├── l │ │ ├── fortun │ │ └── help ├── bin │ └── .gitignore ├── etc │ ├── os-release │ └── profile └── root │ └── example.sh ├── .rustfmt.toml ├── src ├── generated │ ├── mod.rs │ └── .gitignore ├── programs │ ├── common │ │ ├── mod.rs │ │ ├── color_picker.rs │ │ ├── extendable_iterator.rs │ │ ├── shell_commands.rs │ │ └── readline.rs │ ├── clear.rs │ ├── mkdir.rs │ ├── pwd.rs │ ├── whoami.rs │ ├── touch.rs │ ├── sponge.rs │ ├── rmdir.rs │ ├── cp.rs │ ├── mv.rs │ ├── rm.rs │ ├── which.rs │ ├── cat.rs │ ├── rev.rs │ ├── ls.rs │ ├── theme.rs │ ├── find.rs │ ├── head.rs │ ├── tail.rs │ ├── tee.rs │ ├── sort.rs │ ├── sed.rs │ ├── grep.rs │ ├── test.rs │ ├── wc.rs │ ├── fortune.rs │ ├── mod.rs │ ├── cowsay.rs │ ├── echo.rs │ └── vi.rs ├── streams │ ├── file_redirect_out.rs │ ├── mod.rs │ ├── file_redirect_in.rs │ ├── pipe.rs │ ├── output_stream.rs │ ├── input_stream.rs │ └── standard_streams.rs ├── utils.rs ├── lib.rs ├── process.rs ├── filesystem │ ├── mod.rs │ ├── dev.rs │ └── multi.rs └── ansi_codes.rs ├── www ├── .gitignore ├── index.js ├── bootstrap.js ├── .eslintrc.cjs ├── index.html ├── webpack.config.js ├── style.css ├── package.json └── term.js ├── .gitignore ├── .github └── workflows │ ├── yaml.yml │ ├── rust.yml │ └── release.yml ├── LICENSE_MIT ├── Cargo.toml ├── README.md ├── tests └── mod.rs └── Cargo.lock /rootfs/run/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | -------------------------------------------------------------------------------- /rootfs/usr/share/theme/themes/default: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rootfs/bin/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /src/generated/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod rootfs; 2 | -------------------------------------------------------------------------------- /rootfs/usr/bin/[: -------------------------------------------------------------------------------- 1 | #!sh 2 | exec -a [ test ${@} 3 | -------------------------------------------------------------------------------- /src/generated/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !mod.rs 4 | -------------------------------------------------------------------------------- /rootfs/usr/bin/l: -------------------------------------------------------------------------------- 1 | #!sh 2 | # An alias for "ls" 3 | ls ${@} 4 | -------------------------------------------------------------------------------- /www/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.tar 4 | *.tar.gz 5 | -------------------------------------------------------------------------------- /rootfs/usr/bin/fortun: -------------------------------------------------------------------------------- 1 | #!sh 2 | echo 'You will misspell "fortune."' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | pkg/ 4 | wasm-pack.log 5 | *.swp 6 | *.swo 7 | -------------------------------------------------------------------------------- /rootfs/usr/share/theme/themes/eye-sore: -------------------------------------------------------------------------------- 1 | #terminal { 2 | background-color: #ff0; 3 | color: magenta; 4 | } 5 | -------------------------------------------------------------------------------- /src/programs/common/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod color_picker; 2 | pub mod extendable_iterator; 3 | pub mod readline; 4 | pub mod shell_commands; 5 | -------------------------------------------------------------------------------- /rootfs/usr/share/cowsay/licenses: -------------------------------------------------------------------------------- 1 | Licenses for cows 2 | 3 | cow: artistic license by Tony Monroe 4 | ferris: Copyright by reddit user Diggsey 5 | -------------------------------------------------------------------------------- /www/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | begin 3 | } from "its-a-unix-system"; 4 | 5 | async function main() { 6 | await begin(); 7 | } 8 | main(); 9 | -------------------------------------------------------------------------------- /rootfs/etc/os-release: -------------------------------------------------------------------------------- 1 | PRETTY_NAME="Faunix, the Fake UNIX System" 2 | NAME="Faunix" 3 | ID=faunix 4 | HOME_URL="https://github.com/Property404/its-a-unix-system" 5 | -------------------------------------------------------------------------------- /rootfs/usr/share/cowsay/cows/ferris: -------------------------------------------------------------------------------- 1 | \ 2 | \ 3 | _~^~^~_ 4 | \) / o o \ (/ 5 | '_ - _' 6 | / '-----' \ 7 | -------------------------------------------------------------------------------- /rootfs/usr/share/cowsay/cows/cow: -------------------------------------------------------------------------------- 1 | \ ^__^ 2 | \ (oo)\_______ 3 | (__)\ )\/\ 4 | ||----w | 5 | || || 6 | -------------------------------------------------------------------------------- /rootfs/usr/share/theme/themes/hacker: -------------------------------------------------------------------------------- 1 | #terminal { 2 | color: #0f0; 3 | } 4 | 5 | .ct-magenta,.ct-red,.ct-blue,.ct-green,.ct-cyan { 6 | font-weight: bold; 7 | color: #0f0; 8 | } 9 | -------------------------------------------------------------------------------- /rootfs/usr/share/theme/themes/white-on-black: -------------------------------------------------------------------------------- 1 | #terminal { 2 | background-color: #fff; 3 | color: black 4 | } 5 | 6 | .ct-blue { 7 | color: #00f; 8 | } 9 | 10 | .ct-magenta { 11 | color:darkblue; 12 | } 13 | -------------------------------------------------------------------------------- /rootfs/etc/profile: -------------------------------------------------------------------------------- 1 | export EDITOR=vi 2 | export VISUAL=vi 3 | export PS1='\e[35m\W\e[0m $ ' 4 | 5 | echo -e "Hello! Welcome to \x1b[31mFaunix\x1b[0m, the best fake Unix system https://dagans.dev has to offer." 6 | echo "" 7 | help 8 | echo "" 9 | -------------------------------------------------------------------------------- /rootfs/usr/share/theme/themes/blue: -------------------------------------------------------------------------------- 1 | #terminal { 2 | background-color: #aaf; 3 | color: #002 4 | } 5 | 6 | .ct-blue { 7 | color: #00f; 8 | } 9 | 10 | .ct-magenta { 11 | color: darkblue; 12 | } 13 | 14 | .ct-red { 15 | color: darkred; 16 | } 17 | -------------------------------------------------------------------------------- /www/bootstrap.js: -------------------------------------------------------------------------------- 1 | // A dependency graph that contains any wasm must all be imported 2 | // asynchronously. This `bootstrap.js` file does the single async import, so 3 | // that no one else needs to worry about it again. 4 | import("./index.js") 5 | .catch(e => console.error("Error importing `index.js`:", e)); 6 | -------------------------------------------------------------------------------- /rootfs/usr/bin/help: -------------------------------------------------------------------------------- 1 | #!sh 2 | echo "To see available commands, press . 3 | To see help for a command, use the `--help` or `-h` arguments. 4 | 5 | To contribute: https://github.com/Property404/its-a-unix-system" 6 | 7 | test -- "${1}" =~ "." && \ 8 | echo -e "\nThere are no arguments for the `help` command itself" 9 | -------------------------------------------------------------------------------- /www/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es2021": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": "latest", 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | "no-unused-vars": ["error", { "varsIgnorePattern": "js_term" }] 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/yaml.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Yaml CI 3 | 4 | # yamllint disable-line rule:truthy 5 | on: [push, pull_request] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Install yamllint 15 | run: sudo apt install yamllint 16 | - name: Lint yaml files 17 | run: yamllint -- $(find . -name '*.yml' -or -name '*.yaml') 18 | -------------------------------------------------------------------------------- /rootfs/usr/share/games/fortunes/risque: -------------------------------------------------------------------------------- 1 | Your next date will be with a millipede, and you know what they say about men with a lot of feet. 2 | 3 | That attractive woman at the bar is looking at you out of hunger, not lust. 4 | 5 | Spice up your menstruation with our new sexy edible tampons! 6 | 7 | Why not improve your efficiency and get a cloaca? 8 | 9 | C++: for when you're already a little suicidal and need that extra push. 10 | 11 | Do you actually need GATs, or do you just have a fetish? 12 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | It's a Unix system! I know this! 6 | 7 | 8 | 9 | 10 |
Loading...
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /rootfs/root/example.sh: -------------------------------------------------------------------------------- 1 | # This is a comment. You won't see it 2 | 3 | echo "Would you like to see your fortune?" 4 | # `read` reads user input to a new variable `answer` 5 | read -p '(y/n) > ' answer 6 | # `[` evaluates a conditional. In this case, we're checking if 7 | # the variable `answer` is "y". 8 | [ "${answer}" =~ "y" ] || echo -e "Fine. \u0001f621" 9 | # The `-s` flag selects a short fortune. 10 | [ "${answer}" =~ "y" ] && \ 11 | echo -e "Here is a \u0001f42e with your fortune" && \ 12 | fortune -s | cowsay 13 | -------------------------------------------------------------------------------- /src/programs/clear.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | process::{ExitCode, Process}, 3 | AnsiCode, 4 | }; 5 | use anyhow::Result; 6 | use clap::Parser; 7 | use futures::io::AsyncWriteExt; 8 | 9 | /// Clear the screen 10 | #[derive(Parser)] 11 | struct Options {} 12 | 13 | pub async fn clear(process: &mut Process) -> Result { 14 | let _options = Options::try_parse_from(process.args.iter())?; 15 | process 16 | .stdout 17 | .write_all(&AnsiCode::Clear.to_bytes()) 18 | .await?; 19 | Ok(ExitCode::SUCCESS) 20 | } 21 | -------------------------------------------------------------------------------- /src/programs/mkdir.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::Result; 3 | use clap::Parser; 4 | 5 | /// Create directory. 6 | #[derive(Parser)] 7 | struct Options { 8 | /// The directories to create. 9 | #[arg(required(true))] 10 | directories: Vec, 11 | } 12 | 13 | pub async fn mkdir(process: &Process) -> Result { 14 | let options = Options::try_parse_from(process.args.iter())?; 15 | for arg in options.directories.into_iter() { 16 | process.get_path(arg)?.create_dir_all()?; 17 | } 18 | Ok(ExitCode::SUCCESS) 19 | } 20 | -------------------------------------------------------------------------------- /src/programs/pwd.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | filesystem, 3 | process::{ExitCode, Process}, 4 | }; 5 | use anyhow::Result; 6 | use clap::Parser; 7 | use futures::io::AsyncWriteExt; 8 | 9 | /// Print the name of the current working directory 10 | #[derive(Parser)] 11 | struct Options {} 12 | 13 | pub async fn pwd(process: &mut Process) -> Result { 14 | let _options = Options::try_parse_from(process.args.iter())?; 15 | 16 | let cwd = filesystem::vfs_path_to_str(&process.cwd); 17 | 18 | process.stdout.write_all(cwd.as_bytes()).await?; 19 | process.stdout.write_all(b"\n").await?; 20 | Ok(ExitCode::SUCCESS) 21 | } 22 | -------------------------------------------------------------------------------- /src/programs/whoami.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::{anyhow, Result}; 3 | use clap::Parser; 4 | use futures::io::AsyncWriteExt; 5 | 6 | /// Prints the current user. 7 | #[derive(Parser)] 8 | struct Options {} 9 | 10 | pub async fn whoami(process: &mut Process) -> Result { 11 | let _options = Options::try_parse_from(process.args.iter())?; 12 | let user = process 13 | .env 14 | .get("USER") 15 | .ok_or_else(|| anyhow!("Could not get the user"))?; 16 | process.stdout.write_all(user.as_bytes()).await?; 17 | process.stdout.write_all(b"\n").await?; 18 | Ok(ExitCode::SUCCESS) 19 | } 20 | -------------------------------------------------------------------------------- /www/webpack.config.js: -------------------------------------------------------------------------------- 1 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | entry: "./bootstrap.js", 6 | output: { 7 | path: path.resolve(__dirname, "dist"), 8 | filename: "bootstrap.js", 9 | }, 10 | mode: "production", 11 | experiments: { 12 | asyncWebAssembly: true 13 | }, 14 | plugins: [ 15 | new CopyWebpackPlugin( 16 | { 17 | "patterns": ['index.html', 'term.js', 'style.css'] 18 | } 19 | ) 20 | ], 21 | performance: { 22 | maxAssetSize: 5000000, 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/programs/touch.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::Result; 3 | use clap::Parser; 4 | use futures::io::AsyncWriteExt; 5 | 6 | /// Create a file if it does not exist. 7 | #[derive(Parser)] 8 | struct Options { 9 | /// The file(s) to touch or create. 10 | #[arg(required(true))] 11 | files: Vec, 12 | } 13 | 14 | pub async fn touch(process: &mut Process) -> Result { 15 | let options = Options::try_parse_from(process.args.iter())?; 16 | for arg in options.files.into_iter() { 17 | if arg == "me" { 18 | process.stderr.write_all(b"Absolutely not.\n").await?; 19 | } 20 | process.get_path(arg)?.create_file()?; 21 | } 22 | Ok(ExitCode::SUCCESS) 23 | } 24 | -------------------------------------------------------------------------------- /src/programs/sponge.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::Result; 3 | use clap::Parser; 4 | use futures::io::AsyncReadExt; 5 | use std::io::Write; 6 | 7 | /// Soak up standard input and write to file. 8 | #[derive(Parser)] 9 | struct Options { 10 | /// The file to which to write 11 | file: String, 12 | } 13 | 14 | pub async fn sponge(process: &mut Process) -> Result { 15 | let options = Options::try_parse_from(process.args.iter())?; 16 | let mut content = String::new(); 17 | 18 | process.stdin.read_to_string(&mut content).await?; 19 | 20 | let path = process.get_path(options.file)?; 21 | let mut file = path.create_file()?; 22 | file.write_all(content.as_bytes())?; 23 | Ok(ExitCode::SUCCESS) 24 | } 25 | -------------------------------------------------------------------------------- /src/programs/rmdir.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::{bail, Result}; 3 | use clap::Parser; 4 | 5 | /// Remove a directory if empty. 6 | #[derive(Parser)] 7 | struct Options { 8 | /// The directories to remove. 9 | #[arg(required(true))] 10 | dirs: Vec, 11 | } 12 | 13 | pub async fn rmdir(process: &Process) -> Result { 14 | let options = Options::try_parse_from(process.args.iter())?; 15 | 16 | for dir in options.dirs { 17 | let path = process.get_path(dir)?; 18 | 19 | if !path.is_dir()? { 20 | bail!("Not a directory"); 21 | } 22 | 23 | if path.read_dir()?.next().is_some() { 24 | bail!("Directory not empty"); 25 | } 26 | 27 | path.remove_file()?; 28 | } 29 | Ok(ExitCode::SUCCESS) 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Rust 3 | 4 | # yamllint disable-line rule:truthy 5 | on: [push, pull_request] 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest] 18 | rust: [stable] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Run Clippy 23 | run: cargo clippy --all-features -- -D warnings 24 | - name: Run tests 25 | run: cargo test --verbose 26 | - name: Build 27 | run: cargo build --verbose 28 | - name: Lint 29 | run: | 30 | cargo fmt -- $(find src -name '*.rs') --check && \ 31 | cargo fmt -- --check 32 | - name: Build documentation 33 | run: cargo doc --verbose 34 | -------------------------------------------------------------------------------- /src/streams/file_redirect_out.rs: -------------------------------------------------------------------------------- 1 | use crate::streams::{output_stream::OutputStreamBackend, OutputStream, TerminalWriter}; 2 | use anyhow::Result; 3 | use std::io::Write; 4 | 5 | pub fn file_redirect_out( 6 | file: Box, 7 | ) -> (OutputStream, OutputStreamBackend) { 8 | let writer = FileOutWriter { file }; 9 | let (output_stream, output_bkend) = OutputStream::from_writer(writer); 10 | 11 | (output_stream, output_bkend) 12 | } 13 | 14 | pub struct FileOutWriter { 15 | file: Box, 16 | } 17 | 18 | impl TerminalWriter for FileOutWriter { 19 | fn send(&mut self, content: &str) -> Result<()> { 20 | self.file.write_all(content.as_bytes())?; 21 | Ok(()) 22 | } 23 | 24 | fn shutdown(&mut self) -> Result<()> { 25 | Ok(()) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /www/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, body { 6 | margin:0; 7 | padding:0; 8 | width: 100%; 9 | height: 100%; 10 | } 11 | 12 | #terminal { 13 | background-color:black; 14 | color:white; 15 | margin:0; 16 | width: 100%; 17 | height: 100%; 18 | white-space: pre-wrap; 19 | overflow-y: hidden; 20 | font-family: monospace; 21 | font-size: 1rem; 22 | } 23 | 24 | #terminal div { 25 | min-height: 1.2rem; 26 | } 27 | 28 | #cursor { 29 | display:inline-block; 30 | max-width: 0; 31 | } 32 | 33 | .ct-normal {} 34 | .ct-black { color: black; } 35 | .ct-white { color: white; } 36 | .ct-blue { color: #99f; } 37 | .ct-cyan { color: cyan; } 38 | .ct-green { color: green; } 39 | .ct-red { color: red; } 40 | .ct-yellow { color: yellow; } 41 | .ct-magenta { color: magenta; } 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release CI 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | tags: 8 | - '*' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | - name: Build 19 | run: | 20 | rustup target add wasm32-unknown-unknown 21 | cargo install wasm-pack 22 | wasm-pack build --no-default-features 23 | pushd www 24 | npm install 25 | npm run build 26 | popd 27 | - name: Tar dist 28 | run: tar -C www/ -czf its-a-unix-system.tar.gz dist 29 | - name: Create Release 30 | uses: ncipollo/release-action@v1 31 | with: 32 | artifacts: its-a-unix-system.tar.gz 33 | body: Look at me I'm a release haha 34 | -------------------------------------------------------------------------------- /src/programs/cp.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::{bail, Result}; 3 | use clap::Parser; 4 | 5 | /// Copy a file. 6 | #[derive(Parser)] 7 | struct Options { 8 | /// The source file. 9 | src: String, 10 | /// The destination file or directory. 11 | dest: String, 12 | } 13 | 14 | pub async fn cp(process: &mut Process) -> Result { 15 | let options = Options::try_parse_from(process.args.iter())?; 16 | let src = process.get_path(&options.src)?; 17 | if !src.exists()? { 18 | bail!("src does not exist"); 19 | } 20 | 21 | let mut dest = process.get_path(&options.dest)?; 22 | if dest.exists()? && dest.is_dir()? { 23 | dest = dest.join(src.filename())?; 24 | } 25 | // We have to check exists() twice because it could have changed. 26 | if dest.exists()? && dest.is_file()? { 27 | dest.remove_file()?; 28 | } 29 | src.copy_file(&dest)?; 30 | Ok(ExitCode::SUCCESS) 31 | } 32 | -------------------------------------------------------------------------------- /src/programs/mv.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::{bail, Result}; 3 | use clap::Parser; 4 | 5 | /// Move a file. 6 | #[derive(Parser)] 7 | struct Options { 8 | /// The source file. 9 | src: String, 10 | /// The destination file or directory. 11 | dest: String, 12 | } 13 | 14 | pub async fn mv(process: &Process) -> Result { 15 | let options = Options::try_parse_from(process.args.iter())?; 16 | let src = process.get_path(&options.src)?; 17 | if !src.exists()? { 18 | bail!("src does not exist"); 19 | } 20 | 21 | let mut dest = process.get_path(&options.dest)?; 22 | if dest.exists()? && dest.is_dir()? { 23 | dest = dest.join(src.filename())?; 24 | } 25 | // We have to check exists() twice because it could have changed. 26 | if dest.exists()? && dest.is_file()? { 27 | dest.remove_file()?; 28 | } 29 | src.move_file(&dest)?; 30 | Ok(ExitCode::SUCCESS) 31 | } 32 | -------------------------------------------------------------------------------- /src/streams/mod.rs: -------------------------------------------------------------------------------- 1 | mod file_redirect_in; 2 | mod file_redirect_out; 3 | mod input_stream; 4 | mod output_stream; 5 | mod pipe; 6 | mod standard_streams; 7 | use anyhow::Result; 8 | pub use file_redirect_in::file_redirect_in; 9 | pub use file_redirect_out::file_redirect_out; 10 | use futures::try_join; 11 | pub use input_stream::{InputMode, InputStream, InputStreamBackend, TerminalReader}; 12 | pub use output_stream::{OutputStream, OutputStreamBackend, TerminalWriter}; 13 | pub use pipe::pipe; 14 | pub use standard_streams::standard; 15 | 16 | pub struct Backend { 17 | input_bkend: InputStreamBackend, 18 | output_bkend: OutputStreamBackend, 19 | } 20 | 21 | impl Backend { 22 | pub async fn run(&mut self) -> Result<()> { 23 | try_join! { 24 | self.input_bkend.run(), 25 | self.output_bkend.run(), 26 | }?; 27 | Ok(()) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/programs/rm.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::{bail, Result}; 3 | use clap::Parser; 4 | 5 | /// Remove/unlink a file. 6 | #[derive(Parser)] 7 | struct Options { 8 | /// Ignore nonexistent files. 9 | #[arg(short, long)] 10 | force: bool, 11 | /// Recursively remove directories and their contents. 12 | #[arg(short, long)] 13 | recursive: bool, 14 | /// The file(s) to remove 15 | #[arg(required(true))] 16 | files: Vec, 17 | } 18 | 19 | pub async fn rm(process: &Process) -> Result { 20 | let options = Options::try_parse_from(process.args.iter())?; 21 | 22 | for file in options.files { 23 | let path = process.get_path(file)?; 24 | 25 | if !options.recursive && path.is_dir()? { 26 | bail!("cannot operate recursively"); 27 | } 28 | 29 | let result = path.remove_file(); 30 | if !options.force { 31 | result? 32 | } 33 | } 34 | Ok(ExitCode::SUCCESS) 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE_MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022-2024 Dagan Martinez 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /src/programs/which.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::{bail, Result}; 3 | use clap::Parser; 4 | use futures::io::AsyncWriteExt; 5 | 6 | /// Locate a command. 7 | #[derive(Parser)] 8 | struct Options { 9 | /// Print all matching commands. 10 | #[arg(short)] 11 | all: bool, 12 | /// The command to locate. 13 | command: String, 14 | } 15 | 16 | pub async fn which(process: &mut Process) -> Result { 17 | let options = Options::try_parse_from(process.args.iter())?; 18 | 19 | let Some(paths) = process.env.get("PATH") else { 20 | bail!("No ${{PATH}} environmental variable"); 21 | }; 22 | 23 | let mut code = ExitCode::FAILURE; 24 | for path in paths.split(':') { 25 | let path = process.get_path(path)?.join(&options.command)?; 26 | if path.exists()? && path.is_file()? { 27 | process.stdout.write_all(path.as_str().as_bytes()).await?; 28 | process.stdout.write_all(b"\n").await?; 29 | code = ExitCode::SUCCESS; 30 | if !options.all { 31 | break; 32 | } 33 | } 34 | } 35 | 36 | Ok(code) 37 | } 38 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use wasm_bindgen::prelude::*; 3 | use web_sys::{self, Document}; 4 | 5 | #[allow(unused)] 6 | pub fn set_panic_hook() { 7 | // When the `console_error_panic_hook` feature is enabled, we can call the 8 | // `set_panic_hook` function at least once during initialization, and then 9 | // we will get better error messages if our code ever panics. 10 | // 11 | // For more details see 12 | // https://github.com/rustwasm/console_error_panic_hook#readme 13 | #[cfg(feature = "console_error_panic_hook")] 14 | console_error_panic_hook::set_once(); 15 | } 16 | 17 | /// Fetch DOM document object. 18 | pub fn get_document() -> Result { 19 | let Some(document) = web_sys::window().and_then(|window| window.document()) else { 20 | bail!("Could not get root html document"); 21 | }; 22 | Ok(document) 23 | } 24 | 25 | #[wasm_bindgen] 26 | extern "C" { 27 | pub fn js_term_write(s: &str); 28 | pub fn js_term_backspace(); 29 | pub fn js_term_clear(); 30 | pub fn js_term_get_screen_height() -> usize; 31 | } 32 | 33 | #[allow(unused)] 34 | pub fn debug>(s: S) { 35 | js_term_write(s.into().as_str()); 36 | } 37 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "its-a-unix-system-frontend", 3 | "version": "0.1.0", 4 | "description": "A unix terminal for your website", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack --config webpack.config.js", 8 | "format": "js-beautify --end-with-newline *.js", 9 | "lint": "eslint *.js", 10 | "start": "webpack-dev-server --mode development" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/Property404/its-a-unix-system" 15 | }, 16 | "keywords": [ 17 | "webassembly", 18 | "wasm", 19 | "rust", 20 | "webpack" 21 | ], 22 | "author": "Dagan Martinez , Ashley Williams ", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/Property404/its-a-unix-system/issues" 26 | }, 27 | "homepage": "https://github.com/Property404/its-a-unix-system#readme", 28 | "devDependencies": { 29 | "copy-webpack-plugin": "^12.0.2", 30 | "eslint": "^9.13.0", 31 | "its-a-unix-system": "file:../pkg", 32 | "js-beautify": "^1.15.1", 33 | "webpack": "^5.95.0", 34 | "webpack-cli": "^5.1.0", 35 | "webpack-dev-server": "^5.1.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "its-a-unix-system" 3 | description = "A unix terminal for your website" 4 | version = "0.1.2" 5 | authors = ["Property404 "] 6 | edition = "2021" 7 | repository = "https://github.com/Property404/its-a-unix-system" 8 | license = "MIT" 9 | 10 | [lib] 11 | crate-type = ["cdylib", "rlib"] 12 | 13 | [features] 14 | default = ["console_error_panic_hook"] 15 | 16 | [dependencies] 17 | anyhow = "1" 18 | ascii = "1.1" 19 | clap = { version = "4", features = ["derive", "std", "help", "usage", "suggestions"], default-features = false } 20 | console_error_panic_hook = { version = "0.1", optional = true } 21 | futures = "0.3" 22 | getrandom = { version = "0.2", features = ["js"] } 23 | js-sys = "0.3" 24 | rand = "0.8" 25 | regex = "1.11" 26 | sedregex = "0.2" 27 | textwrap = "0.16" 28 | vfs = "0.11" 29 | wasm-bindgen = "0.2" 30 | wasm-bindgen-futures = { version = "0.4", features = ["futures-core", "futures-core-03-stream"] } 31 | web-sys = { version = "0.3", features = ["Document", "Window", "KeyboardEvent", "Element"] } 32 | 33 | [build-dependencies] 34 | anyhow = "1" 35 | walkdir = "2.5" 36 | 37 | [dev-dependencies] 38 | futures-test = "0.3" 39 | 40 | [profile.release] 41 | opt-level = "s" 42 | strip = true 43 | -------------------------------------------------------------------------------- /src/streams/file_redirect_in.rs: -------------------------------------------------------------------------------- 1 | use crate::streams::{InputStream, InputStreamBackend, TerminalReader}; 2 | use futures::stream::{FusedStream, Stream}; 3 | use std::{ 4 | pin::Pin, 5 | task::{Context, Poll}, 6 | }; 7 | use vfs::path::SeekAndRead; 8 | 9 | pub fn file_redirect_in( 10 | file: Box, 11 | ) -> (InputStream, InputStreamBackend) { 12 | let reader = FileInReader { 13 | file, 14 | terminated: false, 15 | }; 16 | let (input_stream, input_bkend) = InputStream::from_reader(reader); 17 | 18 | (input_stream, input_bkend) 19 | } 20 | 21 | pub struct FileInReader { 22 | file: Box, 23 | terminated: bool, 24 | } 25 | 26 | impl TerminalReader for FileInReader {} 27 | 28 | impl Stream for FileInReader { 29 | type Item = Vec; 30 | 31 | fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { 32 | let mut buffer = [0; 32]; 33 | match self.file.read(&mut buffer) { 34 | Err(_) | Ok(0) => { 35 | self.terminated = true; 36 | Poll::Ready(None) 37 | } 38 | Ok(size) => Poll::Ready(Some(buffer[0..size].to_vec())), 39 | } 40 | } 41 | } 42 | 43 | impl FusedStream for FileInReader { 44 | fn is_terminated(&self) -> bool { 45 | self.terminated 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/programs/common/color_picker.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::io::Write; 3 | const RESET: &str = "\u{001b}[0m"; 4 | 5 | #[derive(Clone, Copy, PartialEq, Eq)] 6 | #[allow(unused)] 7 | pub enum Color { 8 | Red, 9 | Blue, 10 | Green, 11 | } 12 | 13 | impl Color { 14 | const fn as_fg(&self) -> &str { 15 | match self { 16 | Self::Red => "\u{001b}[31m", 17 | Self::Green => "\u{001b}[32m", 18 | Self::Blue => "\u{001b}[34m", 19 | } 20 | } 21 | } 22 | 23 | pub struct ColorPicker { 24 | active: bool, 25 | color: Option, 26 | } 27 | 28 | impl ColorPicker { 29 | pub fn new(active: bool) -> Self { 30 | Self { 31 | active, 32 | color: None, 33 | } 34 | } 35 | 36 | pub fn set_color(&mut self, color: Color) { 37 | self.color = Some(color) 38 | } 39 | 40 | pub fn reset(&mut self) { 41 | self.color = None 42 | } 43 | 44 | pub fn write(&self, writer: &mut impl Write, text: &str) -> Result<()> { 45 | if self.active { 46 | if let Some(color) = self.color { 47 | writer.write_all(color.as_fg().as_bytes())?; 48 | } 49 | } 50 | writer.write_all(text.as_bytes())?; 51 | if self.active { 52 | writer.write_all(RESET.as_bytes())?; 53 | } 54 | Ok(()) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/programs/cat.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::{bail, Result}; 3 | use clap::Parser; 4 | use std::io::Write; 5 | 6 | /// Concatenate files. 7 | #[derive(Parser)] 8 | struct Options { 9 | /// The files to concatenate. 10 | files: Vec, 11 | } 12 | 13 | pub async fn cat(process: &mut Process) -> Result { 14 | let options = Options::try_parse_from(process.args.iter())?; 15 | if options.files.is_empty() { 16 | loop { 17 | if let Ok(line) = process.stdin.get_line().await { 18 | process.stdout.write_all(line.as_bytes())?; 19 | process.stdout.write_all(b"\n")?; 20 | } else { 21 | // Ignore 'unexpected end of file' 22 | return Ok(ExitCode::Success); 23 | } 24 | } 25 | } 26 | 27 | let mut contents = String::new(); 28 | for arg in options.files.into_iter() { 29 | let path = process.get_path(arg)?; 30 | if !path.exists()? { 31 | bail!("No such file {}", path.as_str()); 32 | } 33 | if !path.is_file()? { 34 | bail!("{} is not a file", path.as_str()); 35 | } 36 | let mut file = path.open_file()?; 37 | contents.clear(); 38 | file.read_to_string(&mut contents)?; 39 | process.stdout.write_all(contents.as_bytes())? 40 | } 41 | 42 | Ok(ExitCode::SUCCESS) 43 | } 44 | -------------------------------------------------------------------------------- /src/programs/rev.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | process::{ExitCode, Process}, 3 | streams::{file_redirect_in, InputStream, OutputStream}, 4 | }; 5 | use anyhow::Result; 6 | use clap::Parser; 7 | use futures::{io::AsyncWriteExt, try_join}; 8 | 9 | /// Reverse lines characterwise. 10 | #[derive(Parser)] 11 | struct Options { 12 | /// The files to reverse. 13 | files: Vec, 14 | } 15 | 16 | pub async fn rev_inner(stream: &mut InputStream, out: &mut OutputStream) -> Result<()> { 17 | while let Ok(line) = stream.get_line().await { 18 | let line: String = line.chars().rev().collect(); 19 | out.write_all(line.as_bytes()).await?; 20 | out.write_all(b"\n").await?; 21 | } 22 | 23 | Ok(()) 24 | } 25 | 26 | pub async fn rev(process: &mut Process) -> Result { 27 | let options = Options::try_parse_from(process.args.iter())?; 28 | 29 | if options.files.is_empty() { 30 | let mut stdin = process.stdin.clone(); 31 | rev_inner(&mut stdin, &mut process.stdout).await?; 32 | } else { 33 | for file in options.files { 34 | let fp = process.get_path(file)?.open_file()?; 35 | let (mut fp, mut backend) = file_redirect_in(Box::new(fp)); 36 | try_join! { 37 | async { 38 | rev_inner(&mut fp, &mut process.stdout).await?; 39 | fp.shutdown().await?; 40 | Ok(()) 41 | }, 42 | backend.run() 43 | }?; 44 | } 45 | } 46 | Ok(ExitCode::SUCCESS) 47 | } 48 | -------------------------------------------------------------------------------- /src/programs/ls.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use crate::programs::common::color_picker::{Color, ColorPicker}; 3 | use anyhow::{bail, Result}; 4 | use clap::Parser; 5 | 6 | const DIR_COLOR: Color = Color::Blue; 7 | 8 | /// List files/directories. 9 | #[derive(Parser)] 10 | struct Options { 11 | /// Do not ignore hidden files. 12 | #[arg(short, long)] 13 | all: bool, 14 | /// The directory to list. 15 | target: Option, 16 | } 17 | 18 | pub async fn ls(process: &mut Process) -> Result { 19 | let options = Options::try_parse_from(process.args.iter())?; 20 | let path = { 21 | let dir = process.get_path(options.target.unwrap_or_else(|| ".".into()))?; 22 | if !dir.exists()? { 23 | bail!("No such file or directory: {}", dir.as_str()); 24 | } 25 | if !dir.is_dir()? { 26 | bail!("Not a directory: {}", dir.as_str()); 27 | } 28 | dir 29 | }; 30 | 31 | let mut picker = ColorPicker::new(process.stdout.to_terminal().await?); 32 | 33 | // Show '.' and '..', because those don't appear in read_dir() 34 | if options.all { 35 | picker.set_color(DIR_COLOR); 36 | picker.write(&mut process.stdout, ".\n..\n")?; 37 | } 38 | 39 | for entity in path.read_dir()? { 40 | if entity.is_dir()? { 41 | picker.set_color(DIR_COLOR); 42 | } else { 43 | picker.reset(); 44 | } 45 | let display = format!("{}\n", entity.filename()); 46 | if options.all || !display.starts_with('.') { 47 | picker.write(&mut process.stdout, &display)?; 48 | } 49 | } 50 | Ok(ExitCode::SUCCESS) 51 | } 52 | -------------------------------------------------------------------------------- /src/programs/theme.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | process::{ExitCode, Process}, 3 | utils, 4 | }; 5 | use anyhow::{bail, Result}; 6 | use clap::Parser; 7 | use futures::io::AsyncWriteExt; 8 | 9 | const THEMES_DIR: &str = "/usr/share/theme/themes"; 10 | 11 | /// Change the terminal theme. 12 | /// 13 | /// Use without arguments to see available themes. 14 | #[derive(Parser)] 15 | #[command(verbatim_doc_comment)] 16 | struct Options { 17 | /// The theme to switch to. 18 | theme: Option, 19 | } 20 | 21 | pub async fn theme(process: &mut Process) -> Result { 22 | let options = Options::try_parse_from(process.args.iter())?; 23 | 24 | let themes = process.get_path(THEMES_DIR)?; 25 | 26 | if let Some(theme) = &options.theme { 27 | let theme = themes.join(theme)?; 28 | let mut theme_contents = String::new(); 29 | 30 | if !theme.exists()? { 31 | bail!("No such theme: {}", options.theme.expect("BUG: no theme")); 32 | } 33 | 34 | let mut theme = theme.open_file()?; 35 | theme.read_to_string(&mut theme_contents)?; 36 | 37 | let Some(theme_holder) = utils::get_document()?.get_element_by_id("theme-holder") else { 38 | bail!("Failed to get theme holder") 39 | }; 40 | 41 | theme_holder.set_text_content(Some(&theme_contents)); 42 | } else { 43 | process.stdout.write_all(b"Available themes:\n").await?; 44 | for theme in themes.read_dir()? { 45 | let theme = theme.filename(); 46 | process.stdout.write_all(theme.as_bytes()).await?; 47 | process.stdout.write_all(b"\n").await?; 48 | } 49 | } 50 | 51 | Ok(ExitCode::SUCCESS) 52 | } 53 | -------------------------------------------------------------------------------- /src/programs/find.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::{bail, Result}; 3 | use clap::Parser; 4 | use futures::AsyncWriteExt; 5 | use vfs::VfsPath; 6 | 7 | /// Search for files/directories 8 | #[derive(Parser)] 9 | struct Options { 10 | /// The directories to search. 11 | directories: Vec, 12 | } 13 | 14 | pub async fn find(process: &mut Process) -> Result { 15 | let mut options = Options::try_parse_from(process.args.iter())?; 16 | 17 | if options.directories.is_empty() { 18 | options.directories.push(String::from(".")); 19 | } 20 | 21 | let paths: Result> = options 22 | .directories 23 | .into_iter() 24 | .map(|mut path_expression| { 25 | let dir = process.get_path(&path_expression)?; 26 | if !path_expression.ends_with('/') { 27 | path_expression.push('/'); 28 | } 29 | if !dir.exists()? { 30 | bail!("No such file or directory: {}", dir.as_str()); 31 | } 32 | if !dir.is_dir()? { 33 | bail!("Not a directory: {}", dir.as_str()); 34 | } 35 | Ok((dir, path_expression)) 36 | }) 37 | .collect(); 38 | 39 | for (path, expressed_as) in paths? { 40 | for entity in path.walk_dir()? { 41 | let dis = entity?.as_str().to_string(); 42 | let path_str = format!("{}/", path.as_str()); 43 | let mut dis = dis.replacen(path_str.as_str(), expressed_as.as_str(), 1); 44 | dis.push('\n'); 45 | process.stdout.write_all(dis.as_bytes()).await?; 46 | } 47 | } 48 | Ok(ExitCode::SUCCESS) 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # It's a Unix System! I know this! 2 | 3 | WebAssembly Unix terminal built with 🦀Rust🦀 4 | 5 | ## Features 6 | 7 | * Essential Unix commands (sh, ls, cp, mv, cat, cowsay, etc) 8 | * Basic Vi implementation 9 | * Pipes and file redirect 10 | * Variables and subshells 11 | * File system via [rust-vfs](https://github.com/manuel-woelker/rust-vfs) 12 | * Basic scripting support (try `sh example.sh`) 13 | * GNU Readline-like features (key bindings, history, tab-complete) 14 | * ANSI escape code support, including some colors 15 | 16 | ### Known bugs 17 | 18 | * Running `foo=bar echo ${foo}` will print `foo`'s old value 19 | * `[` cannot compare multiword values because of how variable substition works 20 | * No emoji support in `vi` 21 | 22 | ## Example 23 | 24 | ``` 25 | $ fortune -s | cowsay 26 | _____________________________________ 27 | < Your mother is disappointed in you. > 28 | ------------------------------------- 29 | \ ^__^ 30 | \ (oo)\_______ 31 | (__)\ )\/\ 32 | ||----w | 33 | || || 34 | $ # We also have file redirect 35 | $ echo "Wow what a great fortune" > file 36 | $ # We could do `cat file` as well 37 | $ cat < file 38 | Wow what a great fortune 39 | ``` 40 | 41 | ## Building and Serving for Development 42 | 43 | * `cargo install wasm-pack` 44 | * `wasm-pack build --dev` 45 | * `cd www` 46 | * `npm install` 47 | * `npm run start` 48 | 49 | App will be served on port 8080 50 | 51 | ## Building for Release 52 | 53 | * `cargo install wasm-pack` 54 | * `wasm-pack build --no-default-features` 55 | * `cd www` 56 | * `npm install` 57 | * `npm run build` 58 | 59 | Build will be in `www/dist/` 60 | 61 | ## License 62 | 63 | MIT 64 | -------------------------------------------------------------------------------- /src/programs/head.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | process::{ExitCode, Process}, 3 | streams::{file_redirect_in, InputStream, OutputStream}, 4 | }; 5 | use anyhow::Result; 6 | use clap::Parser; 7 | use futures::try_join; 8 | use std::io::Write; 9 | 10 | /// Show first n lines. 11 | #[derive(Parser)] 12 | struct Options { 13 | /// How many lines to show. 14 | #[arg(short, default_value = "10")] 15 | n: usize, 16 | /// The file to show. 17 | file: Option, 18 | } 19 | 20 | pub async fn head_inner( 21 | stdin: &mut InputStream, 22 | stdout: &mut OutputStream, 23 | n: usize, 24 | ) -> Result<()> { 25 | for _ in 0..n { 26 | if let Ok(line) = stdin.get_line().await { 27 | stdout.write_all(line.as_bytes())?; 28 | stdout.write_all(b"\n")?; 29 | } else { 30 | // Ignore 'unexpected end of file' 31 | return Ok(()); 32 | } 33 | } 34 | Ok(()) 35 | } 36 | 37 | pub async fn head(process: &mut Process) -> Result { 38 | let options = Options::try_parse_from(process.args.iter())?; 39 | 40 | let n = options.n; 41 | 42 | if let Some(file) = options.file { 43 | let fp = process.get_path(file)?.open_file()?; 44 | let (mut fp, mut backend) = file_redirect_in(Box::new(fp)); 45 | try_join! { 46 | async { 47 | head_inner(&mut fp, &mut process.stdout, n).await?; 48 | fp.shutdown().await?; 49 | Ok(()) 50 | }, 51 | backend.run() 52 | }?; 53 | } else { 54 | let mut stdout = process.stdout.clone(); 55 | head_inner(&mut process.stdin, &mut stdout, n).await?; 56 | } 57 | 58 | Ok(ExitCode::SUCCESS) 59 | } 60 | -------------------------------------------------------------------------------- /src/programs/tail.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | process::{ExitCode, Process}, 3 | streams::{file_redirect_in, InputStream, OutputStream}, 4 | }; 5 | use anyhow::Result; 6 | use clap::Parser; 7 | use futures::{try_join, AsyncReadExt}; 8 | use std::io::Write; 9 | 10 | /// Show last n lines. 11 | #[derive(Parser)] 12 | struct Options { 13 | /// How many lines to show. 14 | #[arg(short, default_value = "10")] 15 | n: usize, 16 | /// The file to show. 17 | file: Option, 18 | } 19 | 20 | pub async fn tail_inner( 21 | stdin: &mut InputStream, 22 | stdout: &mut OutputStream, 23 | n: usize, 24 | ) -> Result<()> { 25 | let mut contents = String::new(); 26 | stdin.read_to_string(&mut contents).await?; 27 | let contents: Vec<_> = contents.split('\n').collect(); 28 | let contents = contents[contents.len().saturating_sub(n + 1)..].join("\n"); 29 | stdout.write_all(contents.as_bytes())?; 30 | Ok(()) 31 | } 32 | 33 | pub async fn tail(process: &mut Process) -> Result { 34 | let options = Options::try_parse_from(process.args.iter())?; 35 | 36 | let n = options.n; 37 | 38 | if let Some(file) = options.file { 39 | let fp = process.get_path(file)?.open_file()?; 40 | let (mut fp, mut backend) = file_redirect_in(Box::new(fp)); 41 | try_join! { 42 | async { 43 | tail_inner(&mut fp, &mut process.stdout, n).await?; 44 | fp.shutdown().await?; 45 | Ok(()) 46 | }, 47 | backend.run() 48 | }?; 49 | } else { 50 | let mut stdout = process.stdout.clone(); 51 | tail_inner(&mut process.stdin, &mut stdout, n).await?; 52 | } 53 | 54 | Ok(ExitCode::SUCCESS) 55 | } 56 | -------------------------------------------------------------------------------- /src/programs/tee.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | process::{ExitCode, Process}, 3 | streams::file_redirect_out, 4 | }; 5 | use anyhow::Result; 6 | use clap::Parser; 7 | use futures::{future::try_join_all, try_join}; 8 | use std::io::Write; 9 | 10 | /// Read from stdin and write to stdout and file. 11 | #[derive(Parser)] 12 | struct Options { 13 | /// The files to which to write 14 | files: Vec, 15 | } 16 | 17 | pub async fn tee(process: &mut Process) -> Result { 18 | let options = Options::try_parse_from(process.args.iter())?; 19 | let mut outs = Vec::new(); 20 | let mut backends = Vec::new(); 21 | 22 | for file in options.files { 23 | let fp = process.get_path(file)?.create_file()?; 24 | let (out, backend) = file_redirect_out(Box::new(fp)); 25 | outs.push(out); 26 | backends.push(backend); 27 | } 28 | 29 | try_join! { 30 | async { 31 | while let Ok(line) = process.stdin.get_line().await { 32 | process.stdout.write_all(line.as_bytes())?; 33 | process.stdout.write_all(b"\n")?; 34 | for out in &mut outs { 35 | out.write_all(line.as_bytes())?; 36 | out.write_all(b"\n")?; 37 | } 38 | } 39 | for out in &mut outs { 40 | out.shutdown().await?; 41 | } 42 | Ok(()) 43 | }, 44 | async { 45 | let mut futures = Vec::new(); 46 | for backend in &mut backends { 47 | futures.push(backend.run()); 48 | } 49 | try_join_all(futures).await?; 50 | Ok::<(), anyhow::Error>(()) 51 | } 52 | }?; 53 | 54 | Ok(ExitCode::SUCCESS) 55 | } 56 | -------------------------------------------------------------------------------- /src/programs/sort.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::{bail, Result}; 3 | use clap::Parser; 4 | use futures::AsyncReadExt; 5 | use std::{collections::HashSet, io::Write}; 6 | 7 | /// Sort files or stdin. 8 | #[derive(Parser)] 9 | struct Options { 10 | /// Sort in reverse order. 11 | #[arg(short, long)] 12 | reverse: bool, 13 | /// Remove repeated lines. 14 | #[arg(short, long)] 15 | unique: bool, 16 | /// The files to concatenate and sort. 17 | files: Vec, 18 | } 19 | 20 | pub async fn sort(process: &mut Process) -> Result { 21 | let options = Options::try_parse_from(process.args.iter())?; 22 | let mut contents = String::new(); 23 | 24 | if options.files.is_empty() { 25 | process.stdin.read_to_string(&mut contents).await?; 26 | } 27 | 28 | for arg in options.files.into_iter() { 29 | let path = process.get_path(arg)?; 30 | if !path.exists()? { 31 | bail!("No such file {}", path.as_str()); 32 | } 33 | if !path.is_file()? { 34 | bail!("{} is not a file", path.as_str()); 35 | } 36 | let mut file = path.open_file()?; 37 | file.read_to_string(&mut contents)?; 38 | } 39 | 40 | let mut lines: Vec<&str> = contents.lines().collect(); 41 | if options.unique { 42 | let set = lines.into_iter().collect::>(); 43 | lines = set.into_iter().collect(); 44 | } 45 | 46 | lines.sort(); 47 | 48 | if options.reverse { 49 | lines = lines.into_iter().rev().collect(); 50 | } 51 | 52 | for line in lines { 53 | process.stdout.write_all(line.as_bytes())?; 54 | process.stdout.write_all(b"\n")?; 55 | } 56 | 57 | Ok(ExitCode::SUCCESS) 58 | } 59 | -------------------------------------------------------------------------------- /src/programs/sed.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | process::{ExitCode, Process}, 3 | streams::{file_redirect_in, InputStream, OutputStream}, 4 | }; 5 | use anyhow::Result; 6 | use clap::Parser; 7 | use futures::try_join; 8 | use sedregex::ReplaceCommand; 9 | use std::io::Write; 10 | 11 | /// Stream edit by regex 12 | #[derive(Parser)] 13 | struct Options { 14 | /// The regex pattern to use. 15 | pattern: String, 16 | /// The files to filter. 17 | files: Vec, 18 | } 19 | 20 | async fn sed_inner<'a>( 21 | stream: &mut InputStream, 22 | out: &mut OutputStream, 23 | pattern: &ReplaceCommand<'a>, 24 | ) -> Result<()> { 25 | while let Ok(line) = stream.get_line().await { 26 | let line = pattern.execute(&line); 27 | out.write_all(line.as_bytes())?; 28 | out.write_all(b"\n")?; 29 | } 30 | 31 | Ok(()) 32 | } 33 | 34 | pub async fn sed(process: &mut Process) -> Result { 35 | let options = Options::try_parse_from(process.args.iter())?; 36 | let pattern = ReplaceCommand::new(&options.pattern)?; 37 | 38 | if options.files.is_empty() { 39 | let mut stdin = process.stdin.clone(); 40 | sed_inner(&mut stdin, &mut process.stdout, &pattern).await?; 41 | } else { 42 | for file in options.files { 43 | let fp = process.get_path(file)?.open_file()?; 44 | let (mut fp, mut backend) = file_redirect_in(Box::new(fp)); 45 | try_join! { 46 | async { 47 | sed_inner(&mut fp, &mut process.stdout, &pattern).await?; 48 | fp.shutdown().await?; 49 | Ok(()) 50 | }, 51 | backend.run() 52 | }?; 53 | } 54 | } 55 | Ok(ExitCode::SUCCESS) 56 | } 57 | -------------------------------------------------------------------------------- /src/programs/common/extendable_iterator.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::VecDeque, 3 | iter::{Extend, Iterator}, 4 | }; 5 | 6 | /// An iterator that can be extended. 7 | pub struct ExtendableIterator(VecDeque); 8 | 9 | impl ExtendableIterator { 10 | /// Construct a new ExtendableIterator. 11 | pub fn new>(it: U) -> Self { 12 | let vec = it.collect(); 13 | Self(vec) 14 | } 15 | 16 | /// Prepend to iterator. 17 | pub fn prepend(&mut self, mut iter: U) 18 | where 19 | U: DoubleEndedIterator, 20 | { 21 | while let Some(item) = iter.next_back() { 22 | self.0.push_front(item) 23 | } 24 | } 25 | 26 | /// Check if iterator is empty. 27 | pub fn is_empty(&self) -> bool { 28 | self.0.is_empty() 29 | } 30 | } 31 | 32 | impl Iterator for ExtendableIterator { 33 | type Item = T; 34 | 35 | fn next(&mut self) -> Option { 36 | self.0.pop_front() 37 | } 38 | } 39 | impl Extend for ExtendableIterator { 40 | fn extend(&mut self, iter: U) 41 | where 42 | U: IntoIterator, 43 | { 44 | self.0.extend(iter) 45 | } 46 | } 47 | 48 | #[cfg(test)] 49 | mod tests { 50 | use super::*; 51 | 52 | #[test] 53 | fn prepend_iterator() { 54 | let text = "Hi"; 55 | let mut it = ExtendableIterator::new(text.chars()); 56 | it.prepend("Ho".chars()); 57 | assert_eq!(it.next().unwrap(), 'H'); 58 | assert_eq!(it.next().unwrap(), 'o'); 59 | assert_eq!(it.next().unwrap(), 'H'); 60 | assert_eq!(it.next().unwrap(), 'i'); 61 | assert_eq!(it.next(), None); 62 | } 63 | 64 | #[test] 65 | fn extend_iterator() { 66 | let text = "Hi"; 67 | let mut it = ExtendableIterator::new(text.chars()); 68 | it.extend("Ho".chars()); 69 | assert_eq!(it.next().unwrap(), 'H'); 70 | assert_eq!(it.next().unwrap(), 'i'); 71 | assert_eq!(it.next().unwrap(), 'H'); 72 | assert_eq!(it.next().unwrap(), 'o'); 73 | assert_eq!(it.next(), None); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/programs/grep.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | process::{ExitCode, Process}, 3 | streams::{file_redirect_in, InputStream, OutputStream}, 4 | }; 5 | use anyhow::Result; 6 | use clap::Parser; 7 | use futures::try_join; 8 | use regex::Regex; 9 | use std::io::Write; 10 | 11 | /// Filter files by regex. 12 | #[derive(Parser)] 13 | struct Options { 14 | /// The regex pattern to use. 15 | pattern: String, 16 | /// The files to filter. 17 | files: Vec, 18 | /// Select non-matching lines. 19 | #[arg(short = 'v', long)] 20 | invert_match: bool, 21 | /// Ignore case. 22 | #[arg(short, long)] 23 | ignore_case: bool, 24 | } 25 | 26 | async fn grep_inner( 27 | stream: &mut InputStream, 28 | out: &mut OutputStream, 29 | pattern: &Regex, 30 | invert: bool, 31 | ) -> Result<()> { 32 | while let Ok(line) = stream.get_line().await { 33 | if pattern.is_match(&line) != invert { 34 | out.write_all(line.as_bytes())?; 35 | out.write_all(b"\n")?; 36 | } 37 | } 38 | 39 | Ok(()) 40 | } 41 | 42 | pub async fn grep(process: &mut Process) -> Result { 43 | let options = Options::try_parse_from(process.args.iter())?; 44 | let pattern = if options.ignore_case { 45 | format!("(?i){}", options.pattern) 46 | } else { 47 | options.pattern 48 | }; 49 | let pattern = Regex::new(&pattern)?; 50 | 51 | if options.files.is_empty() { 52 | let mut stdin = process.stdin.clone(); 53 | grep_inner( 54 | &mut stdin, 55 | &mut process.stdout, 56 | &pattern, 57 | options.invert_match, 58 | ) 59 | .await?; 60 | } else { 61 | for file in options.files { 62 | let fp = process.get_path(file)?.open_file()?; 63 | let (mut fp, mut backend) = file_redirect_in(Box::new(fp)); 64 | try_join! { 65 | async { 66 | grep_inner(&mut fp, &mut process.stdout, &pattern, options.invert_match).await?; 67 | fp.shutdown().await?; 68 | Ok(()) 69 | }, 70 | backend.run() 71 | }?; 72 | } 73 | } 74 | Ok(ExitCode::SUCCESS) 75 | } 76 | -------------------------------------------------------------------------------- /src/programs/test.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::{bail, Result}; 3 | use clap::Parser; 4 | use regex::Regex; 5 | 6 | /// Evaluate conditional expression. 7 | #[derive(Parser)] 8 | struct Options { 9 | /// The first argument. 10 | arg1: String, 11 | /// The operation. 12 | op: String, 13 | /// The second argument. 14 | arg2: String, 15 | } 16 | 17 | pub async fn test(process: &Process) -> Result { 18 | if process.args.is_empty() { 19 | bail!("No arguments"); 20 | } 21 | 22 | let mut invert = false; 23 | 24 | // If we're aliased with '[', then end with ']' 25 | let args = if process.args[0] == "[" { 26 | if process.args.last().expect("No last item") != "]" { 27 | bail!("Expected ']'"); 28 | } 29 | &process.args[0..process.args.len() - 1] 30 | } else { 31 | &process.args[..] 32 | }; 33 | 34 | // Invert? 35 | let args = if args.get(1).map(|s| s == "!").unwrap_or(false) { 36 | invert = true; 37 | &args[1..] 38 | } else { 39 | args 40 | }; 41 | 42 | let options = Options::try_parse_from(args)?; 43 | 44 | let mut result = match options.op.as_str() { 45 | // Equal 46 | "==" => { 47 | if options.arg1 == options.arg2 { 48 | ExitCode::SUCCESS 49 | } else { 50 | ExitCode::FAILURE 51 | } 52 | } 53 | // Not equal 54 | "!=" => { 55 | if options.arg1 != options.arg2 { 56 | ExitCode::SUCCESS 57 | } else { 58 | ExitCode::FAILURE 59 | } 60 | } 61 | // Regex match 62 | "=~" => { 63 | let pattern = Regex::new(&options.arg2)?; 64 | if pattern.is_match(&options.arg1) { 65 | ExitCode::SUCCESS 66 | } else { 67 | ExitCode::FAILURE 68 | } 69 | } 70 | _ => bail!("Unknown argument"), 71 | }; 72 | 73 | if invert { 74 | if result == ExitCode::SUCCESS { 75 | result = ExitCode::FAILURE 76 | } else { 77 | result = ExitCode::SUCCESS 78 | } 79 | } 80 | 81 | Ok(result) 82 | } 83 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod ansi_codes; 2 | pub mod filesystem; 3 | mod generated; 4 | pub mod process; 5 | pub mod programs; 6 | pub mod streams; 7 | mod utils; 8 | use ansi_codes::{AnsiCode, ControlChar}; 9 | use anyhow::Result; 10 | use futures::{io::AsyncWriteExt, try_join}; 11 | use process::Process; 12 | use wasm_bindgen::prelude::*; 13 | 14 | const PROFILE_PATH: &str = "/etc/profile"; 15 | const HOME_PATH: &str = "/root"; 16 | const BIN_PATHS: &str = "/bin:/usr/bin"; 17 | const USER: &str = "root"; 18 | 19 | async fn run() -> Result<()> { 20 | utils::set_panic_hook(); 21 | 22 | let (stdin, stdout, mut backend, signal_registrar) = streams::standard()?; 23 | 24 | let rootfs = filesystem::get_root()?; 25 | let mut process = Process { 26 | stdin: stdin.clone(), 27 | stdout: stdout.clone(), 28 | stderr: stdout.clone(), 29 | env: Default::default(), 30 | signal_registrar, 31 | cwd: rootfs.join(HOME_PATH)?, 32 | args: vec!["-sh".into(), "-s".into(), PROFILE_PATH.into()], 33 | }; 34 | 35 | for (key, value) in [("USER", USER), ("HOME", HOME_PATH), ("PATH", BIN_PATHS)] { 36 | process.env.insert(key.into(), value.into()); 37 | } 38 | 39 | try_join!(backend.run(), async { 40 | // We can clear the loading screen now. 41 | process 42 | .stdout 43 | .write_all(&AnsiCode::Clear.to_bytes()) 44 | .await?; 45 | 46 | // Warn if we're on a development build 47 | #[cfg(debug_assertions)] 48 | process 49 | .stdout 50 | .write_all(b"\x1b[33m[WARN: this is a dev build]\x1b[0m\n") 51 | .await?; 52 | 53 | loop { 54 | let mut child = process.clone(); 55 | programs::exec_program(&mut child, "sh").await?; 56 | process 57 | .stderr 58 | .write_all(b"Oops! Looks like you exited your shell.\n") 59 | .await?; 60 | process 61 | .stderr 62 | .write_all(b"Let me get a fresh one for you.\n") 63 | .await?; 64 | } 65 | // So return type can be inferred. 66 | #[allow(unreachable_code)] 67 | Ok(()) 68 | })?; 69 | Ok(()) 70 | } 71 | 72 | #[wasm_bindgen] 73 | pub async fn begin() { 74 | run().await.unwrap() 75 | } 76 | -------------------------------------------------------------------------------- /src/process.rs: -------------------------------------------------------------------------------- 1 | use crate::streams::{InputStream, OutputStream}; 2 | use anyhow::Result; 3 | use futures::channel::{mpsc::UnboundedSender, oneshot}; 4 | use std::{collections::HashMap, num::NonZeroU8}; 5 | use vfs::VfsPath; 6 | 7 | #[derive(Clone)] 8 | pub struct Process { 9 | pub stdin: InputStream, 10 | pub stdout: OutputStream, 11 | pub stderr: OutputStream, 12 | pub env: HashMap, 13 | pub cwd: VfsPath, 14 | pub args: Vec, 15 | // Used by a process to indicate it's listening for signals 16 | // Currently we just have the ^C signal, but we might add more 17 | // later. I don't know, I'm tired 18 | pub signal_registrar: UnboundedSender>, 19 | } 20 | 21 | impl Process { 22 | /// Get VFS path from , as you would type into a Unix shell. 23 | pub fn get_path(&self, path: impl AsRef) -> Result { 24 | let mut path = path.as_ref(); 25 | 26 | while path.len() > 1 && path.ends_with('/') { 27 | path = &path[0..path.len() - 1] 28 | } 29 | 30 | Ok(self.cwd.join(path)?) 31 | } 32 | } 33 | 34 | /// Value returned from a Process. 35 | #[repr(u8)] 36 | #[derive(Copy, Clone, Default, PartialEq, Eq)] 37 | pub enum ExitCode { 38 | #[default] 39 | Success, 40 | Failure(NonZeroU8), 41 | } 42 | 43 | impl From for ExitCode { 44 | fn from(other: u8) -> Self { 45 | match other { 46 | 0 => ExitCode::SUCCESS, 47 | // Panic not possible because we've already matched for zero. 48 | code => ExitCode::Failure(NonZeroU8::new(code).expect("BUG: Invalid failure code")), 49 | } 50 | } 51 | } 52 | 53 | impl From for u8 { 54 | fn from(other: ExitCode) -> Self { 55 | match other { 56 | ExitCode::SUCCESS => 0, 57 | ExitCode::Failure(code) => code.get(), 58 | } 59 | } 60 | } 61 | 62 | impl ExitCode { 63 | pub const FAILURE: Self = ExitCode::Failure(NonZeroU8::MIN); 64 | pub const SUCCESS: Self = ExitCode::Success; 65 | 66 | /// Returns true if this is a succcess variant. 67 | pub const fn is_success(&self) -> bool { 68 | matches!(self, ExitCode::Success) 69 | } 70 | 71 | /// Returns true if this is a failure variant. 72 | pub const fn is_failure(&self) -> bool { 73 | matches!(self, ExitCode::Failure(_)) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/programs/wc.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | process::{ExitCode, Process}, 3 | streams::{file_redirect_in, InputStream, OutputStream}, 4 | }; 5 | use anyhow::Result; 6 | use clap::Parser; 7 | use futures::try_join; 8 | use std::io::Write; 9 | 10 | /// Print line, word, and byte counts for a file. 11 | #[derive(Parser)] 12 | struct Options { 13 | /// Print the byte count. 14 | #[arg(short = 'c', long)] 15 | bytes: bool, 16 | /// Print the word count. 17 | #[arg(short, long)] 18 | words: bool, 19 | /// Print the line count. 20 | #[arg(short, long)] 21 | lines: bool, 22 | /// The files to count. 23 | files: Vec, 24 | } 25 | 26 | async fn wc_inner<'a>( 27 | stream: &mut InputStream, 28 | out: &mut OutputStream, 29 | options: &Options, 30 | ) -> Result<()> { 31 | let mut lines = 0; 32 | let mut words = 0; 33 | let mut bytes = 0; 34 | while let Ok(line) = stream.get_line().await { 35 | bytes += line.len(); 36 | words += line.split_whitespace().count(); 37 | lines += 1; 38 | } 39 | let mut stats = Vec::new(); 40 | if options.lines { 41 | stats.push(format!("{lines}")); 42 | } 43 | if options.words { 44 | stats.push(format!("{words}")); 45 | } 46 | if options.bytes { 47 | stats.push(format!("{bytes}")); 48 | } 49 | out.write_all(format!("{}\n", stats.join(" ")).as_bytes())?; 50 | Ok(()) 51 | } 52 | 53 | pub async fn wc(process: &mut Process) -> Result { 54 | let mut options = Options::try_parse_from(process.args.iter())?; 55 | 56 | if !(options.bytes || options.words || options.lines) { 57 | options.bytes = true; 58 | options.words = true; 59 | options.lines = true; 60 | } 61 | 62 | if options.files.is_empty() { 63 | let mut stdin = process.stdin.clone(); 64 | wc_inner(&mut stdin, &mut process.stdout, &options).await?; 65 | } else { 66 | for file in &options.files { 67 | let fp = process.get_path(file)?.open_file()?; 68 | let (mut fp, mut backend) = file_redirect_in(Box::new(fp)); 69 | try_join! { 70 | async { 71 | wc_inner(&mut fp, &mut process.stdout, &options).await?; 72 | fp.shutdown().await?; 73 | Ok(()) 74 | }, 75 | backend.run() 76 | }?; 77 | } 78 | } 79 | Ok(ExitCode::SUCCESS) 80 | } 81 | -------------------------------------------------------------------------------- /src/filesystem/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::generated::rootfs::populate_rootfs; 2 | use anyhow::Result; 3 | use std::{collections::HashMap, io}; 4 | use vfs::{MemoryFS, VfsPath}; 5 | mod dev; 6 | mod multi; 7 | use dev::{Device, DeviceFS}; 8 | use multi::MultiFS; 9 | 10 | // `/dev/null` implementation 11 | #[derive(Debug)] 12 | struct NullDevice {} 13 | 14 | impl io::Write for NullDevice { 15 | fn write(&mut self, buf: &[u8]) -> io::Result { 16 | Ok(buf.len()) 17 | } 18 | 19 | fn flush(&mut self) -> io::Result<()> { 20 | Ok(()) 21 | } 22 | } 23 | 24 | impl io::Seek for NullDevice { 25 | fn seek(&mut self, _pos: io::SeekFrom) -> io::Result { 26 | Ok(0) 27 | } 28 | } 29 | 30 | impl io::Read for NullDevice { 31 | fn read(&mut self, _buf: &mut [u8]) -> io::Result { 32 | Ok(0) 33 | } 34 | } 35 | 36 | impl Device for NullDevice { 37 | fn clone_box(&self) -> Box { 38 | Box::new(NullDevice {}) 39 | } 40 | } 41 | 42 | /// Get the RootFS as a VfsPath. 43 | pub fn get_root() -> Result { 44 | let mut memfs: VfsPath = MemoryFS::new().into(); 45 | populate_rootfs(&mut memfs)?; 46 | 47 | let mut devices: HashMap> = HashMap::new(); 48 | devices.insert(String::from("/null"), Box::new(NullDevice {})); 49 | let devfs: VfsPath = DeviceFS::new(devices).into(); 50 | 51 | let mut root = MultiFS::new(memfs)?; 52 | root.push("/dev", devfs)?; 53 | 54 | let root: VfsPath = root.into(); 55 | debug_assert!(root.join("/usr").unwrap().exists().unwrap()); 56 | debug_assert!(root.join("/usr/share").unwrap().exists().unwrap()); 57 | debug_assert!(root.join("/bin/fortune").unwrap().exists().unwrap()); 58 | debug_assert!(root.join("/dev").unwrap().exists().unwrap()); 59 | debug_assert!(root.join("/dev/null").unwrap().exists().unwrap()); 60 | 61 | Ok(root) 62 | } 63 | 64 | /// Convert VfsPath to str 65 | pub fn vfs_path_to_str(path: &VfsPath) -> &str { 66 | let path = path.as_str(); 67 | if path.is_empty() { 68 | "/" 69 | } else { 70 | path 71 | } 72 | } 73 | 74 | #[cfg(test)] 75 | mod test { 76 | use super::*; 77 | 78 | #[test] 79 | fn embedded_fs() { 80 | let path = get_root().unwrap(); 81 | 82 | assert!(path.exists().unwrap()); 83 | assert!(path.join("usr").unwrap().exists().unwrap()); 84 | assert!(path 85 | .join("usr/share/games/fortunes") 86 | .unwrap() 87 | .exists() 88 | .unwrap()); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/programs/fortune.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::{bail, Result}; 3 | use clap::Parser; 4 | use rand::seq::SliceRandom; 5 | use std::io::Write; 6 | 7 | const NORMAL_FORTUNES: &str = "/usr/share/games/fortunes/fortunes"; 8 | const RISQUE_FORTUNES: &str = "/usr/share/games/fortunes/risque"; 9 | const REPEAT_FILE: &str = "/run/fortunes.history"; 10 | 11 | /// Generate a fortune, quote, or wise adage. 12 | #[derive(Parser)] 13 | struct Options { 14 | /// Only show short fortunes. 15 | #[arg(short)] 16 | short: bool, 17 | /// Include risqué fortunes. 18 | #[arg(short = 'r')] 19 | risque: bool, 20 | } 21 | 22 | pub async fn fortune(process: &mut Process) -> Result { 23 | let options = Options::try_parse_from(process.args.iter())?; 24 | // We keep a history of past fortunes so we don't repeat too often. 25 | let (fortunes_told, mut repeat_file) = { 26 | let path = process.get_path(REPEAT_FILE)?; 27 | let mut repeat_file = { 28 | if let Ok(file) = path.open_file() { 29 | file 30 | } else { 31 | path.create_file()?; 32 | path.open_file()? 33 | } 34 | }; 35 | let mut fortunes_told = String::new(); 36 | repeat_file.read_to_string(&mut fortunes_told)?; 37 | let repeat_file = path.append_file()?; 38 | (fortunes_told, repeat_file) 39 | }; 40 | 41 | let mut fortunes = String::new(); 42 | let mut file = process.get_path(NORMAL_FORTUNES)?.open_file()?; 43 | file.read_to_string(&mut fortunes)?; 44 | if options.risque { 45 | let mut file = process.get_path(RISQUE_FORTUNES)?.open_file()?; 46 | fortunes.push('\n'); 47 | file.read_to_string(&mut fortunes)?; 48 | } 49 | let fortunes = fortunes.trim().split("\n\n").collect::>(); 50 | 51 | let mut loops = 0; 52 | loop { 53 | let Some(fortune) = fortunes.choose(&mut rand::thread_rng()) else { 54 | bail!("Could not select a fortune"); 55 | }; 56 | 57 | // Try to not give a fortune already given. 58 | if (loops < 5 && fortunes_told.contains(fortune)) || 59 | // Only give short fortunes if requested. 60 | (options.short && fortune.len() >= 80) 61 | { 62 | loops += 1; 63 | continue; 64 | } 65 | 66 | process.stdout.write_all(fortune.as_bytes())?; 67 | process.stdout.write_all(b"\n")?; 68 | repeat_file.write_all(fortune.as_bytes())?; 69 | repeat_file.write_all(b"\n")?; 70 | return Ok(ExitCode::SUCCESS); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/streams/pipe.rs: -------------------------------------------------------------------------------- 1 | use crate::streams::{Backend, InputStream, OutputStream, TerminalReader, TerminalWriter}; 2 | use anyhow::{anyhow, Result}; 3 | use futures::{ 4 | channel::mpsc::{self, UnboundedReceiver, UnboundedSender}, 5 | stream::{FusedStream, Stream}, 6 | }; 7 | use std::{ 8 | pin::Pin, 9 | task::{Context, Poll}, 10 | }; 11 | 12 | pub fn pipe() -> (InputStream, OutputStream, Backend) { 13 | let (tx, stream) = mpsc::unbounded(); 14 | let writer = PipeWriter { tx }; 15 | let reader = PipeReader { stream }; 16 | let (output_stream, output_bkend) = OutputStream::from_writer(writer); 17 | let (input_stream, input_bkend) = InputStream::from_reader(reader); 18 | let backend = Backend { 19 | input_bkend, 20 | output_bkend, 21 | }; 22 | 23 | (input_stream, output_stream, backend) 24 | } 25 | 26 | pub struct PipeWriter { 27 | tx: UnboundedSender>, 28 | } 29 | 30 | impl TerminalWriter for PipeWriter { 31 | fn send(&mut self, content: &str) -> Result<()> { 32 | self.tx 33 | .unbounded_send(content.as_bytes().to_vec()) 34 | .map_err(|_| anyhow!("Broken pipe"))?; 35 | Ok(()) 36 | } 37 | fn shutdown(&mut self) -> Result<()> { 38 | self.tx.close_channel(); 39 | Ok(()) 40 | } 41 | } 42 | 43 | pub struct PipeReader { 44 | stream: UnboundedReceiver>, 45 | } 46 | 47 | impl TerminalReader for PipeReader { 48 | fn shutdown(&mut self) -> Result<()> { 49 | self.stream.close(); 50 | Ok(()) 51 | } 52 | } 53 | 54 | impl Stream for PipeReader { 55 | type Item = Vec; 56 | 57 | fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 58 | Pin::new(&mut self.stream).poll_next(cx) 59 | } 60 | } 61 | 62 | impl FusedStream for PipeReader { 63 | fn is_terminated(&self) -> bool { 64 | self.stream.is_terminated() 65 | } 66 | } 67 | 68 | #[cfg(test)] 69 | mod test { 70 | use super::*; 71 | use futures::{io::AsyncWriteExt, try_join}; 72 | 73 | #[futures_test::test] 74 | async fn make_pipe() { 75 | let (mut pin, mut pout, mut backend) = pipe(); 76 | try_join! { 77 | backend.run(), 78 | async { 79 | pout.write_all(b"Hello\nWorld!\n").await?; 80 | assert_eq!(pin.get_line().await?, "Hello"); 81 | assert_eq!(pin.get_line().await?, "World!"); 82 | pout.shutdown().await?; 83 | pin.shutdown().await?; 84 | Ok(()) 85 | } 86 | } 87 | .unwrap(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/programs/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::{anyhow, Result}; 3 | use std::io::Write; 4 | mod common; 5 | 6 | pub use sh::sh as shell; 7 | 8 | // Run a program from '/bin' or somewhere. 9 | async fn exec_external_program( 10 | process: &mut Process, 11 | command: &str, 12 | ) -> Result>> { 13 | let root = process.cwd.root(); 14 | 15 | if command.starts_with('/') || command.starts_with("./") { 16 | let mut contents = String::new(); 17 | process 18 | .get_path(command)? 19 | .open_file()? 20 | .read_to_string(&mut contents)?; 21 | let mut ctx = sh::ShellContext::default(); 22 | return Ok(Some(sh::run_script(&mut ctx, process, &contents).await)); 23 | } 24 | 25 | let paths = process 26 | .env 27 | .get("PATH") 28 | .ok_or_else(|| anyhow!("PATH not set"))? 29 | .split(':') 30 | .map(|path| root.join(path)); 31 | 32 | for path in paths { 33 | let path = path?; 34 | for entity in path.read_dir()? { 35 | if entity.is_file()? && entity.filename() == command { 36 | let mut contents = String::new(); 37 | entity.open_file()?.read_to_string(&mut contents)?; 38 | let mut ctx = sh::ShellContext::default(); 39 | return Ok(Some(sh::run_script(&mut ctx, process, &contents).await)); 40 | } 41 | } 42 | } 43 | 44 | Ok(None) 45 | } 46 | 47 | macro_rules! implement { 48 | ($($cmd:ident),*) => { 49 | $( 50 | mod $cmd; 51 | )* 52 | pub async fn exec_program(process: &mut Process, command: &str) -> Result> { 53 | let result = $( 54 | if command == stringify!($cmd) { 55 | Some($cmd::$cmd(process).await) 56 | } else 57 | )* 58 | { 59 | exec_external_program(process, command).await? 60 | }; 61 | 62 | Ok(match result { 63 | None => None, 64 | Some(Ok(code)) => Some(code), 65 | Some(Err(e)) => { 66 | process.stderr.write_all(command.as_bytes())?; 67 | process.stderr.write_all(b": ")?; 68 | process.stderr.write_all(e.to_string().as_bytes())?; 69 | process.stderr.write_all(b"\n")?; 70 | Some(ExitCode::FAILURE) 71 | } 72 | }) 73 | } 74 | } 75 | } 76 | 77 | implement!( 78 | cat, clear, cowsay, cp, echo, fortune, find, grep, head, ls, mkdir, mv, pwd, rev, rm, rmdir, 79 | sed, sh, sort, sponge, tail, tee, test, theme, touch, vi, wc, which, whoami 80 | ); 81 | -------------------------------------------------------------------------------- /src/ansi_codes.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, string::ToString}; 2 | 3 | #[allow(unused)] 4 | #[derive(Copy, Clone)] 5 | /// Various ANSI escape sequences. 6 | pub enum AnsiCode { 7 | CursorUp, 8 | CursorDown, 9 | CursorRight, 10 | CursorLeft, 11 | CursorResetColumn, 12 | Clear, 13 | ClearLine, 14 | ClearToEndOfLine, 15 | AbsolutePosition(usize, usize), 16 | /// Pop the top line. Faunix extension. 17 | PopTop, 18 | /// Pop the bottom line. Faunix extension. 19 | PopBottom, 20 | /// Add a new line to top. Faunix extension. 21 | PushTop, 22 | } 23 | 24 | impl AnsiCode { 25 | /// Get byte representation of ANSI code. 26 | pub fn to_bytes(self) -> Vec { 27 | self.to_string().into_bytes() 28 | } 29 | } 30 | 31 | impl fmt::Display for AnsiCode { 32 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { 33 | write!( 34 | f, 35 | "{}", 36 | match self { 37 | AnsiCode::CursorUp => "\x1b[A".into(), 38 | AnsiCode::CursorDown => "\x1b[B".into(), 39 | AnsiCode::CursorRight => "\x1b[C".into(), 40 | AnsiCode::CursorLeft => "\x1b[D".into(), 41 | AnsiCode::CursorResetColumn => "\x1b[G".into(), 42 | AnsiCode::Clear => "\x1b[c".into(), 43 | AnsiCode::ClearLine => "\x1b[2K".into(), 44 | AnsiCode::ClearToEndOfLine => "\x1b[0K".into(), 45 | AnsiCode::AbsolutePosition(row, column) => { 46 | debug_assert!(row < &1000); 47 | debug_assert!(column < &1000); 48 | format!("\x1b[{row};{column}H") 49 | } 50 | // Pop the top line. 51 | AnsiCode::PopTop => "\x1b[popt".into(), 52 | AnsiCode::PopBottom => "\x1b[popb".into(), 53 | AnsiCode::PushTop => "\x1b[pusht".into(), 54 | } 55 | ) 56 | } 57 | } 58 | 59 | /// Represents a control character. 60 | #[repr(u8)] 61 | #[derive(Clone, Copy, PartialEq, Eq)] 62 | #[allow(dead_code)] 63 | pub enum ControlChar { 64 | A = 1, 65 | B, 66 | C, 67 | D, 68 | E, 69 | F, 70 | G, 71 | H, 72 | I, 73 | J, 74 | K, 75 | L, 76 | M, 77 | N, 78 | O, 79 | P, 80 | Q, 81 | R, 82 | S, 83 | T, 84 | U, 85 | V, 86 | W, 87 | X, 88 | Y, 89 | Z, 90 | } 91 | 92 | impl PartialEq for ControlChar { 93 | fn eq(&self, c: &char) -> bool { 94 | (*self as u8) == (*c as u8) 95 | } 96 | } 97 | impl PartialEq for char { 98 | fn eq(&self, c: &ControlChar) -> bool { 99 | (*self as u8) == (*c as u8) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/filesystem/dev.rs: -------------------------------------------------------------------------------- 1 | //! A filesystem for "files" with custom behavior. 2 | 3 | use std::{collections::HashMap, io::Write}; 4 | use vfs::{ 5 | error::VfsErrorKind, 6 | {FileSystem, SeekAndRead, VfsFileType, VfsMetadata, VfsResult}, 7 | }; 8 | 9 | pub trait Device: Write + SeekAndRead + Send + Sync + std::fmt::Debug { 10 | fn metadata(&self) -> VfsMetadata { 11 | VfsMetadata { 12 | file_type: VfsFileType::File, 13 | len: 0, 14 | } 15 | } 16 | fn clone_box(&self) -> Box; 17 | } 18 | 19 | /// A filesytem that allows custom read/write behavior for individual files. 20 | #[derive(Debug)] 21 | pub struct DeviceFS { 22 | devices: HashMap>, 23 | } 24 | 25 | impl DeviceFS { 26 | /// Create a new DeviceFS with the given devices. 27 | pub fn new(devices: HashMap>) -> Self { 28 | DeviceFS { devices } 29 | } 30 | 31 | fn get_device(&self, path: &str) -> VfsResult> { 32 | Ok(self 33 | .devices 34 | .get(path) 35 | .ok_or(VfsErrorKind::FileNotFound)? 36 | .clone_box()) 37 | } 38 | } 39 | 40 | impl FileSystem for DeviceFS { 41 | fn read_dir(&self, path: &str) -> VfsResult + Send>> { 42 | #[allow(clippy::needless_collect)] 43 | let entries: Vec<_> = self 44 | .devices 45 | .iter() 46 | .filter_map(|tuple| { 47 | if tuple.0.starts_with(path) { 48 | Some(tuple.0.clone()) 49 | } else { 50 | None 51 | } 52 | }) 53 | .collect(); 54 | 55 | Ok(Box::new(entries.into_iter())) 56 | } 57 | 58 | fn create_dir(&self, _path: &str) -> VfsResult<()> { 59 | Err(VfsErrorKind::Other("Unimplemented".into()).into()) 60 | } 61 | 62 | fn open_file(&self, path: &str) -> VfsResult> { 63 | Ok(Box::new(self.get_device(path)?)) 64 | } 65 | 66 | fn create_file(&self, path: &str) -> VfsResult> { 67 | Ok(Box::new(self.get_device(path)?)) 68 | } 69 | 70 | fn append_file(&self, path: &str) -> VfsResult> { 71 | Ok(Box::new(self.get_device(path)?)) 72 | } 73 | 74 | fn metadata(&self, path: &str) -> VfsResult { 75 | if path.is_empty() { 76 | return Ok(VfsMetadata { 77 | file_type: VfsFileType::Directory, 78 | len: 0, 79 | }); 80 | } 81 | Ok(self.get_device(path)?.metadata()) 82 | } 83 | 84 | fn exists(&self, path: &str) -> VfsResult { 85 | Ok(path.is_empty() || self.get_device(path).is_ok()) 86 | } 87 | 88 | fn remove_file(&self, _path: &str) -> VfsResult<()> { 89 | Err(VfsErrorKind::Other("Unimplemented".into()).into()) 90 | } 91 | 92 | fn remove_dir(&self, _path: &str) -> VfsResult<()> { 93 | Err(VfsErrorKind::Other("Unimplemented".into()).into()) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/programs/cowsay.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::Result; 3 | use clap::Parser; 4 | use futures::io::AsyncReadExt; 5 | use std::io::Write; 6 | 7 | const MAX_WIDTH: usize = 40; 8 | const COWS_DIR: &str = "/usr/share/cowsay/cows"; 9 | const DEFAULT_COW: &str = "cow"; 10 | 11 | /// Have a cow say things 12 | #[derive(Parser)] 13 | struct Options { 14 | /// The things to say. 15 | args: Vec, 16 | /// List cow files. 17 | #[arg(short)] 18 | list: bool, 19 | /// The cowfile to use. 20 | #[arg(short)] 21 | file: Option, 22 | } 23 | 24 | pub async fn cowsay(process: &mut Process) -> Result { 25 | let options = Options::try_parse_from(process.args.iter())?; 26 | 27 | if options.list { 28 | process.stdout.write_all(b"Cows in ")?; 29 | process.stdout.write_all(COWS_DIR.as_bytes())?; 30 | process.stdout.write_all(b":\n")?; 31 | for file in process.cwd.join(COWS_DIR)?.read_dir()? { 32 | process.stdout.write_all(file.filename().as_bytes())?; 33 | process.stdout.write_all(b"\n")?; 34 | } 35 | return Ok(ExitCode::SUCCESS); 36 | } 37 | 38 | let text = if options.args.is_empty() { 39 | let mut text = String::new(); 40 | process.stdin.read_to_string(&mut text).await?; 41 | text 42 | } else { 43 | options.args.into_iter().collect::>().join(" ") 44 | }; 45 | 46 | let lines = textwrap::wrap(text.trim(), MAX_WIDTH); 47 | let mut width = 0; 48 | for line in lines.iter() { 49 | if line.len() > width { 50 | width = line.len(); 51 | } 52 | } 53 | 54 | process.stdout.write_all(b" ")?; 55 | for _ in 0..width + 2 { 56 | process.stdout.write_all(b"_")?; 57 | } 58 | process.stdout.write_all(b"\n")?; 59 | 60 | for (index, line) in lines.iter().enumerate() { 61 | process.stdout.write_all(if lines.len() > 1 { 62 | if index == 0 { 63 | b"/" 64 | } else if index == lines.len() - 1 { 65 | b"\\" 66 | } else { 67 | b"|" 68 | } 69 | } else { 70 | b"<" 71 | })?; 72 | process.stdout.write_all(b" ")?; 73 | process.stdout.write_all(line.as_bytes())?; 74 | for _ in 0..=width - line.len() { 75 | process.stdout.write_all(b" ")?; 76 | } 77 | process.stdout.write_all(if lines.len() > 1 { 78 | if index == 0 { 79 | b"\\" 80 | } else if index == lines.len() - 1 { 81 | b"/" 82 | } else { 83 | b"|" 84 | } 85 | } else { 86 | b">" 87 | })?; 88 | process.stdout.write_all(b"\n")?; 89 | } 90 | process.stdout.write_all(b" ")?; 91 | for _ in 0..width + 2 { 92 | process.stdout.write_all(b"-")?; 93 | } 94 | process.stdout.write_all(b"\n")?; 95 | 96 | let cow_file = options.file.unwrap_or_else(|| DEFAULT_COW.into()); 97 | let mut cow_file = process.cwd.join(COWS_DIR)?.join(cow_file)?.open_file()?; 98 | let mut cow = Vec::new(); 99 | cow_file.read_to_end(&mut cow)?; 100 | 101 | process.stdout.write_all(&cow)?; 102 | Ok(ExitCode::SUCCESS) 103 | } 104 | -------------------------------------------------------------------------------- /src/programs/echo.rs: -------------------------------------------------------------------------------- 1 | use crate::process::{ExitCode, Process}; 2 | use anyhow::Result; 3 | use ascii::AsciiChar; 4 | use clap::Parser; 5 | use futures::io::AsyncWriteExt; 6 | 7 | /// Echo args to standard out. 8 | #[derive(Parser)] 9 | struct Options { 10 | /// Do not append a newline. 11 | #[arg(short)] 12 | no_newline: bool, 13 | /// Interpret escape sequences 14 | #[arg(short)] 15 | escapes: bool, 16 | /// The arguments to echo. 17 | args: Vec, 18 | } 19 | 20 | pub async fn echo(process: &mut Process) -> Result { 21 | let options = Options::try_parse_from(process.args.iter())?; 22 | let mut args = options.args.into_iter(); 23 | let mut ends_with_line_feed = false; 24 | 25 | if let Some(item) = args.next() { 26 | let item = if options.escapes { 27 | unescape(&item) 28 | } else { 29 | item 30 | }; 31 | process.stdout.write_all(item.as_bytes()).await?; 32 | ends_with_line_feed = item.ends_with('\n'); 33 | } 34 | for item in args { 35 | let item = if options.escapes { 36 | unescape(&item) 37 | } else { 38 | item 39 | }; 40 | process.stdout.write_all(b" ").await?; 41 | process.stdout.write_all(item.as_bytes()).await?; 42 | ends_with_line_feed = item.ends_with('\n'); 43 | } 44 | 45 | // echo on Linux seems to not print an extra newline if stuff-to-be-echoed ends with a newline 46 | // already. Which kind of makes sense from a usabilty perspective. 47 | // Let's emulate that. 48 | if !options.no_newline && !ends_with_line_feed { 49 | process.stdout.write_all(b"\n").await?; 50 | } 51 | 52 | Ok(ExitCode::SUCCESS) 53 | } 54 | 55 | // Unescape an escaped string. 56 | fn unescape(escaped: &str) -> String { 57 | let mut escaped = escaped.chars(); 58 | let mut unescaped = String::new(); 59 | loop { 60 | let Some(c) = escaped.next() else { break }; 61 | if c == '\\' { 62 | let c = match escaped.next().unwrap_or('\\') { 63 | 'e' => AsciiChar::ESC.as_char(), 64 | 'b' => AsciiChar::BackSpace.as_char(), 65 | 't' => '\t', 66 | '0' => '\0', 67 | 'r' => '\r', 68 | 'n' => '\n', 69 | '\\' => '\\', 70 | 'x' => { 71 | let value = 16 * escaped.next().and_then(|c| c.to_digit(16)).unwrap_or(0) 72 | + escaped.next().and_then(|c| c.to_digit(16)).unwrap_or(0); 73 | if let Some(c) = char::from_u32(value) { 74 | unescaped.push(c); 75 | } 76 | continue; 77 | } 78 | 'u' => { 79 | let mut value: u32 = 0; 80 | for _ in 0..8 { 81 | value *= 16; 82 | value += escaped.next().and_then(|c| c.to_digit(16)).unwrap_or(0); 83 | } 84 | if let Some(c) = char::from_u32(value) { 85 | unescaped.push(c); 86 | } 87 | continue; 88 | } 89 | x => x, 90 | }; 91 | unescaped.push(c); 92 | } else { 93 | unescaped.push(c); 94 | } 95 | } 96 | 97 | unescaped 98 | } 99 | 100 | #[cfg(test)] 101 | mod test { 102 | use super::*; 103 | 104 | #[test] 105 | fn escape_sequences() { 106 | assert_eq!(unescape("h\\\\ello\\nworld\\t"), "h\\ello\nworld\t"); 107 | 108 | assert_eq!(unescape("\\x08"), "\x08"); 109 | assert_eq!(unescape("\\x34"), "\x34"); 110 | 111 | // Edge case: Ending in backslash 112 | assert_eq!(unescape("hi\\"), "hi\\"); 113 | assert_eq!(unescape("hi\\\\"), "hi\\"); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /tests/mod.rs: -------------------------------------------------------------------------------- 1 | //! Integration tests. 2 | use anyhow::Result; 3 | use futures::{ 4 | channel::mpsc::{self, UnboundedSender}, 5 | stream::StreamExt, 6 | try_join, 7 | }; 8 | use its_a_unix_system::{filesystem, process::Process, programs, streams}; 9 | 10 | #[derive(PartialEq, Eq)] 11 | enum Command { 12 | Run(String), 13 | Expect(String), 14 | } 15 | 16 | struct Tester(UnboundedSender); 17 | impl Tester { 18 | fn run(&self, value: &str) -> Result<()> { 19 | self.0.unbounded_send(Command::Run(value.into()))?; 20 | Ok(()) 21 | } 22 | 23 | fn expect(&self, value: &str) -> Result<()> { 24 | self.0.unbounded_send(Command::Expect(value.into()))?; 25 | Ok(()) 26 | } 27 | } 28 | 29 | async fn integration_test_inner(tx: UnboundedSender) -> Result<()> { 30 | let tester = Tester(tx); 31 | 32 | // Basic 33 | tester.run("echo hi | tee a")?; 34 | tester.expect("hi")?; 35 | tester.run("cat a")?; 36 | tester.expect("hi")?; 37 | tester.run("cat < a")?; 38 | tester.expect("hi")?; 39 | tester.run("echo hello >> a")?; 40 | tester.run("cat a | sort")?; 41 | tester.expect("hello")?; 42 | tester.expect("hi")?; 43 | tester.run("rm a")?; 44 | 45 | // Environmental variables 46 | tester.run("export foo=bar")?; 47 | tester.run("echo ${foo}${foo}")?; 48 | tester.expect("barbar")?; 49 | tester.run("export foo=foo${foo}")?; 50 | tester.run("echo ${foo}")?; 51 | tester.expect("foobar")?; 52 | // Make sure quoting works as expected 53 | tester.run("export foo=\"y'all\tare ugly\"")?; 54 | tester.run("echo \"${foo}\"")?; 55 | tester.expect("y'all\tare ugly")?; 56 | tester.run("echo ${foo}")?; 57 | tester.expect("y'all are ugly")?; 58 | // ...and with double quotes inside 59 | tester.run("export foo='quote \"'")?; 60 | tester.run("echo \"${foo}\"")?; 61 | tester.expect("quote \"")?; 62 | // And that we don't recurse shell vars 63 | tester.run("sh -c 'echo -- ${2}'")?; 64 | tester.expect("echo -- ${2}")?; 65 | 66 | // && and || 67 | tester.run("false || echo false")?; 68 | tester.expect("false")?; 69 | tester.run("true || echo butts")?; 70 | tester.run("true && echo true")?; 71 | tester.expect("true")?; 72 | // Tests 73 | tester.run("test a == a && echo yes")?; 74 | tester.expect("yes")?; 75 | tester.run("test a != a || echo no")?; 76 | tester.expect("no")?; 77 | tester.run("test ! a != a || echo alpha")?; 78 | tester.run("test ! a != a && echo beta")?; 79 | tester.expect("beta")?; 80 | tester.run("test yes =~ y && echo yes")?; 81 | tester.expect("yes")?; 82 | 83 | // Semicolons 84 | tester.run("echo -n hello;echo ' world'")?; 85 | tester.expect("hello world")?; 86 | 87 | Ok(()) 88 | } 89 | 90 | #[futures_test::test] 91 | async fn integration_test() -> Result<()> { 92 | let (mut stdin, stdin_tx, mut stdin_backend) = streams::pipe(); 93 | let (mut stdout_rx, stdout, mut stdout_backend) = streams::pipe(); 94 | let (signal_registrar, mut signal_registrar_tx) = mpsc::unbounded(); 95 | let (command_tx, mut command_rx) = mpsc::unbounded(); 96 | let rootfs = filesystem::get_root()?; 97 | 98 | let mut shell = Process { 99 | stdin: stdin.clone(), 100 | stdout: stdout.clone(), 101 | stderr: stdout.clone(), 102 | env: Default::default(), 103 | signal_registrar, 104 | cwd: rootfs, 105 | args: vec!["-sh".into()], 106 | }; 107 | shell.env.insert("PATH".into(), "bin".into()); 108 | 109 | try_join!( 110 | stdin_backend.run(), 111 | stdout_backend.run(), 112 | integration_test_inner(command_tx), 113 | async { 114 | while let Some(command) = command_rx.next().await { 115 | match command { 116 | Command::Run(value) => { 117 | shell.args = vec!["sh".into(), "-c".into(), value]; 118 | programs::shell(&mut shell).await.unwrap(); 119 | } 120 | Command::Expect(value) => { 121 | assert_eq!(value, stdout_rx.get_line().await?); 122 | } 123 | } 124 | } 125 | 126 | // Could be concurrent 127 | stdout.shutdown().await?; 128 | stdin.shutdown().await?; 129 | stdout_rx.shutdown().await?; 130 | stdin_tx.shutdown().await?; 131 | signal_registrar_tx.close(); 132 | Ok(()) 133 | } 134 | )?; 135 | Ok(()) 136 | } 137 | -------------------------------------------------------------------------------- /src/filesystem/multi.rs: -------------------------------------------------------------------------------- 1 | //! An branching file system combining two or more filesystems. 2 | use std::{collections::HashSet, io::Write}; 3 | use vfs::{ 4 | error::VfsErrorKind, 5 | {FileSystem, SeekAndRead, VfsMetadata, VfsPath, VfsResult}, 6 | }; 7 | 8 | // check if `ancestor` is an ancestor of `child` 9 | fn contains(ancestor: &VfsPath, child: &VfsPath) -> bool { 10 | let mut child = child.clone(); 11 | while !child.is_root() { 12 | child = child.parent(); 13 | if &child == ancestor { 14 | return true; 15 | } 16 | } 17 | false 18 | } 19 | 20 | /// An file system combining several filesystems into one. 21 | /// 22 | /// Each layer is a branch of the root path. 23 | #[derive(Debug, Clone)] 24 | pub struct MultiFS { 25 | root: VfsPath, 26 | layers: Vec<(VfsPath, VfsPath)>, 27 | } 28 | 29 | impl MultiFS { 30 | /// Create a new MultiFS filesystem from the given root. 31 | pub fn new(root: VfsPath) -> VfsResult { 32 | if !root.is_root() { 33 | return Err(VfsErrorKind::Other("Root is not a root path".into()).into()); 34 | } 35 | 36 | Ok(MultiFS { 37 | layers: [(root.clone(), root.clone())].into(), 38 | root, 39 | }) 40 | } 41 | 42 | /// Append a new filesystem `local_root` coming out of path `path`, relative to root. 43 | pub fn push(&mut self, path: &str, local_root: VfsPath) -> VfsResult<()> { 44 | let path = self.root.join(path)?; 45 | 46 | if !local_root.is_root() { 47 | return Err(VfsErrorKind::Other("local root is not a root path".into()).into()); 48 | } 49 | 50 | self.layers.push((path, local_root)); 51 | self.layers 52 | .sort_by(|a, b| b.0.as_str().len().cmp(&a.0.as_str().len())); 53 | Ok(()) 54 | } 55 | 56 | fn get_path(&self, mut path_str: &str) -> VfsResult { 57 | if path_str.ends_with('/') { 58 | path_str = &path_str[0..path_str.len() - 1]; 59 | } 60 | 61 | let path = self.root.join(path_str)?; 62 | for (layer, local_root) in &self.layers { 63 | if layer == &path { 64 | return Ok(local_root.clone()); 65 | } 66 | if contains(layer, &path) { 67 | let path_str = &path_str[layer.as_str().len()..]; 68 | return local_root.join(path_str); 69 | } 70 | } 71 | Err(VfsErrorKind::FileNotFound.into()) 72 | } 73 | 74 | fn ensure_has_parent(&self, path: &str) -> VfsResult<()> { 75 | let separator = path.rfind('/'); 76 | if let Some(index) = separator { 77 | let parent_path = &path[..index]; 78 | if self.exists(parent_path)? { 79 | self.get_path(parent_path)?.create_dir_all()?; 80 | return Ok(()); 81 | } 82 | } 83 | Err(VfsErrorKind::Other("!Parent path does not exist".into()).into()) 84 | } 85 | } 86 | 87 | impl FileSystem for MultiFS { 88 | fn read_dir(&self, path: &str) -> VfsResult + Send>> { 89 | let path = self.get_path(path)?; 90 | 91 | let mut entries = HashSet::::new(); 92 | for path in path.read_dir()? { 93 | entries.insert(path.filename()); 94 | } 95 | 96 | for (layer, _) in &self.layers { 97 | if layer.parent() == path && !layer.is_root() { 98 | entries.insert(layer.filename()); 99 | } 100 | } 101 | 102 | Ok(Box::new(entries.into_iter())) 103 | } 104 | 105 | fn create_dir(&self, path: &str) -> VfsResult<()> { 106 | self.get_path(path)?.create_dir_all()?; 107 | Ok(()) 108 | } 109 | 110 | fn open_file(&self, path: &str) -> VfsResult> { 111 | self.get_path(path)?.open_file() 112 | } 113 | 114 | fn create_file(&self, path: &str) -> VfsResult> { 115 | self.ensure_has_parent(path)?; 116 | let result = self.get_path(path)?.create_file()?; 117 | Ok(result) 118 | } 119 | 120 | fn append_file(&self, path: &str) -> VfsResult> { 121 | let write_path = self.get_path(path)?; 122 | if !write_path.exists()? { 123 | self.ensure_has_parent(path)?; 124 | self.get_path(path)?.copy_file(&write_path)?; 125 | } 126 | write_path.append_file() 127 | } 128 | 129 | fn metadata(&self, path: &str) -> VfsResult { 130 | self.get_path(path)?.metadata() 131 | } 132 | 133 | fn exists(&self, path: &str) -> VfsResult { 134 | self.get_path(path)?.exists() 135 | } 136 | 137 | fn remove_file(&self, path: &str) -> VfsResult<()> { 138 | let write_path = self.get_path(path)?; 139 | if write_path.exists()? { 140 | write_path.remove_file()?; 141 | } 142 | Ok(()) 143 | } 144 | 145 | fn remove_dir(&self, path: &str) -> VfsResult<()> { 146 | let write_path = self.get_path(path)?; 147 | if write_path.exists()? { 148 | write_path.remove_dir()?; 149 | } 150 | Ok(()) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/streams/output_stream.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use futures::{ 3 | channel::{ 4 | mpsc::{self, UnboundedReceiver, UnboundedSender}, 5 | oneshot, 6 | }, 7 | io::AsyncWrite, 8 | select, 9 | stream::StreamExt, 10 | }; 11 | use std::{ 12 | io, 13 | pin::Pin, 14 | task::{Context, Poll}, 15 | }; 16 | 17 | const NEWLINE: u8 = 0x0a; 18 | const CARRIAGE_RETURN: u8 = 0x0c; 19 | 20 | pub trait TerminalWriter: Sized + Send { 21 | fn send(&mut self, content: &str) -> Result<()>; 22 | fn shutdown(&mut self) -> Result<()>; 23 | /// Is this being output to a terminal? 24 | fn to_terminal(&self) -> bool { 25 | false 26 | } 27 | } 28 | 29 | enum Command { 30 | Bytes(Vec), 31 | Flush, 32 | ToTerminal(oneshot::Sender), 33 | Shutdown(oneshot::Sender<()>), 34 | } 35 | 36 | pub struct OutputStreamBackend { 37 | writer: T, 38 | rx: UnboundedReceiver, 39 | } 40 | 41 | impl OutputStreamBackend { 42 | fn new(writer: T, rx: UnboundedReceiver) -> Self { 43 | Self { writer, rx } 44 | } 45 | 46 | pub async fn run(&mut self) -> Result<()> { 47 | let mut buffer = Vec::new(); 48 | loop { 49 | select! { 50 | command = self.rx.next() => { 51 | match command.expect("End of command channel") { 52 | Command::Bytes(bytes) => { 53 | let mut flush = false; 54 | for byte in bytes { 55 | buffer.push(byte); 56 | if byte == NEWLINE || byte == CARRIAGE_RETURN { 57 | flush = true; 58 | } 59 | } 60 | if flush { 61 | self.write(&buffer)?; 62 | buffer.clear(); 63 | } 64 | }, 65 | Command::Flush => { 66 | if ! buffer.is_empty() { 67 | self.write(&buffer)?; 68 | buffer.clear(); 69 | } 70 | }, 71 | Command::Shutdown(signal) => { 72 | if ! buffer.is_empty() { 73 | self.write(&buffer)?; 74 | buffer.clear(); 75 | } 76 | self.writer.shutdown()?; 77 | signal.send(()).expect("Could not send shutdown signal"); 78 | return Ok(()); 79 | }, 80 | Command::ToTerminal(signal) => { 81 | let _ = signal.send(self.writer.to_terminal()); 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | fn write(&mut self, buf: &[u8]) -> Result<()> { 90 | let buffer = std::str::from_utf8(buf)?; 91 | self.writer.send(buffer)?; 92 | 93 | Ok(()) 94 | } 95 | } 96 | 97 | #[derive(Clone)] 98 | pub struct OutputStream { 99 | tx: UnboundedSender, 100 | } 101 | 102 | impl OutputStream { 103 | pub fn from_writer(writer: T) -> (Self, OutputStreamBackend) { 104 | let (tx, rx) = mpsc::unbounded(); 105 | let backend = OutputStreamBackend::new(writer, rx); 106 | (Self { tx }, backend) 107 | } 108 | 109 | pub async fn shutdown(&self) -> Result<()> { 110 | let (tx, rx) = oneshot::channel::<()>(); 111 | self.tx.unbounded_send(Command::Shutdown(tx))?; 112 | rx.await?; 113 | self.tx.close_channel(); 114 | Ok(()) 115 | } 116 | 117 | /// Query the backend to check if this is really being output to a terminal. 118 | pub async fn to_terminal(&self) -> Result { 119 | let (tx, rx) = oneshot::channel::(); 120 | self.tx.unbounded_send(Command::ToTerminal(tx))?; 121 | Ok(rx.await?) 122 | } 123 | } 124 | 125 | impl io::Write for OutputStream { 126 | fn write(&mut self, buf: &[u8]) -> io::Result { 127 | self.tx 128 | .unbounded_send(Command::Bytes(buf.to_vec())) 129 | .map_err(|_| io::ErrorKind::Other)?; 130 | Ok(buf.len()) 131 | } 132 | 133 | fn flush(&mut self) -> io::Result<()> { 134 | self.tx 135 | .unbounded_send(Command::Flush) 136 | .map_err(|_| io::ErrorKind::Other)?; 137 | Ok(()) 138 | } 139 | } 140 | 141 | impl AsyncWrite for OutputStream { 142 | fn poll_write( 143 | self: Pin<&mut Self>, 144 | cx: &mut Context<'_>, 145 | buf: &[u8], 146 | ) -> Poll> { 147 | let mut tx = self.tx.clone(); 148 | match tx.poll_ready(cx) { 149 | Poll::Pending => Poll::Pending, 150 | Poll::Ready(Ok(())) => { 151 | if tx.start_send(Command::Bytes(buf.to_vec())).is_err() { 152 | return Poll::Ready(Err(io::ErrorKind::Other.into())); 153 | } 154 | Poll::Ready(Ok(buf.len())) 155 | } 156 | Poll::Ready(Err(_)) => Poll::Ready(Err(io::ErrorKind::Other.into())), 157 | } 158 | } 159 | 160 | fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 161 | let mut tx = self.tx.clone(); 162 | match tx.poll_ready(cx) { 163 | Poll::Pending => Poll::Pending, 164 | Poll::Ready(Ok(())) => { 165 | if tx.start_send(Command::Flush).is_err() { 166 | return Poll::Ready(Err(io::ErrorKind::Other.into())); 167 | } 168 | Poll::Ready(Ok(())) 169 | } 170 | Poll::Ready(Err(_)) => Poll::Ready(Err(io::ErrorKind::Other.into())), 171 | } 172 | } 173 | 174 | fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { 175 | unimplemented!("Cannot close"); 176 | } 177 | } 178 | 179 | #[cfg(test)] 180 | mod test { 181 | use super::*; 182 | use futures::{io::AsyncWriteExt, try_join}; 183 | 184 | #[derive(Default)] 185 | struct MockTerminalWriter { 186 | pub content: String, 187 | } 188 | 189 | impl TerminalWriter for MockTerminalWriter { 190 | fn send(&mut self, content: &str) -> Result<()> { 191 | self.content += content; 192 | Ok(()) 193 | } 194 | fn shutdown(&mut self) -> Result<()> { 195 | Ok(()) 196 | } 197 | } 198 | 199 | #[futures_test::test] 200 | async fn test() { 201 | let writer = MockTerminalWriter::default(); 202 | let (mut stream, mut backend) = OutputStream::from_writer(writer); 203 | 204 | try_join!(backend.run(), async move { 205 | let _ = stream.write("Hello World!".as_bytes()).await?; 206 | stream.flush().await?; 207 | let _ = stream.write("\nGoodbye\n".as_bytes()).await?; 208 | stream.shutdown().await?; 209 | Ok(()) 210 | }) 211 | .unwrap(); 212 | assert_eq!(backend.writer.content, "Hello World!\nGoodbye\n") 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/programs/common/shell_commands.rs: -------------------------------------------------------------------------------- 1 | //! Internal shell commands. 2 | //! 3 | //! Note that these don't take arguments from the process, because that process is the shell 4 | //! itself. 5 | 6 | use crate::{ 7 | filesystem::vfs_path_to_str, 8 | process::{ExitCode, Process}, 9 | programs::{ 10 | self, 11 | common::readline::{NullHistory, Readline}, 12 | sh::ShellContext, 13 | }, 14 | }; 15 | use anyhow::{bail, Result}; 16 | use clap::Parser; 17 | use futures::AsyncWriteExt; 18 | 19 | /// List of all internal shell commands. 20 | pub const COMMANDS: [&str; 7] = ["cd", "env", "export", "read", "exit", "exec", "source"]; 21 | 22 | /// Exit shell. 23 | pub async fn exit( 24 | ctx: &mut ShellContext, 25 | _process: &mut Process, 26 | args: Vec, 27 | ) -> Result { 28 | /// Exit shell. 29 | #[derive(Parser)] 30 | struct Options { 31 | /// The exit status. 32 | status: Option, 33 | } 34 | 35 | let options = Options::try_parse_from(args.iter())?; 36 | let code = options.status.map(ExitCode::from).unwrap_or_default(); 37 | ctx.do_exit_with = Some(code); 38 | Ok(code) 39 | } 40 | 41 | pub async fn exec( 42 | ctx: &mut ShellContext, 43 | process: &mut Process, 44 | args: Vec, 45 | ) -> Result { 46 | /// Replace shell with program. 47 | #[derive(Parser)] 48 | struct Options { 49 | /// The program to run. 50 | command: String, 51 | /// Pass name as zeroth argument. 52 | #[arg(short = 'a')] 53 | name: Option, 54 | /// The arguments to pass, starting with argv[1]. 55 | args: Vec, 56 | } 57 | let options = Options::try_parse_from(args.iter())?; 58 | 59 | if let Some(name) = options.name { 60 | process.args = vec![name]; 61 | } else { 62 | process.args = vec![options.command.clone()]; 63 | } 64 | process.args.extend(options.args); 65 | 66 | let Some(code) = programs::exec_program(process, &options.command).await? else { 67 | bail!("Cannot find {}", options.command); 68 | }; 69 | 70 | ctx.do_exit_with = Some(code); 71 | Ok(code) 72 | } 73 | 74 | pub async fn source( 75 | ctx: &mut ShellContext, 76 | process: &mut Process, 77 | args: Vec, 78 | ) -> Result { 79 | /// Execute commands from file in current shell. 80 | #[derive(Parser)] 81 | struct Options { 82 | /// The program to run. 83 | file: String, 84 | /// The arguments to pass 85 | args: Vec, 86 | } 87 | let options = Options::try_parse_from(args.iter())?; 88 | 89 | let mut script = String::new(); 90 | process 91 | .get_path(&options.file)? 92 | .open_file()? 93 | .read_to_string(&mut script)?; 94 | 95 | let old_arguments = process.args.clone(); 96 | process.args = vec![options.file.clone()]; 97 | process.args.extend(options.args); 98 | 99 | let result = programs::sh::run_script(ctx, process, &script).await; 100 | 101 | process.args = old_arguments; 102 | result 103 | } 104 | 105 | /// Change directory. 106 | pub async fn cd(process: &mut Process, args: Vec) -> Result { 107 | /// Change directory. 108 | #[derive(Parser)] 109 | struct Options { 110 | /// The directory to enter. 111 | directory: Option, 112 | } 113 | let options = Options::try_parse_from(args.iter())?; 114 | 115 | if let Some(directory) = options.directory { 116 | // `cd -` changes to the previous directory 117 | let new_path = if directory == "-" { 118 | if let Some(old_pwd) = process.env.get("OLDPWD") { 119 | process.get_path(old_pwd)? 120 | } else { 121 | bail!("OLDPWD not set"); 122 | } 123 | } else { 124 | process.get_path(directory)? 125 | }; 126 | 127 | if new_path.is_dir()? { 128 | process 129 | .env 130 | .insert("OLDPWD".into(), vfs_path_to_str(&process.cwd).into()); 131 | process.cwd = new_path; 132 | process 133 | .env 134 | .insert("PWD".into(), vfs_path_to_str(&process.cwd).into()); 135 | } else { 136 | process.stderr.write_all(b"cd: ").await?; 137 | process 138 | .stdout 139 | .write_all(new_path.as_str().as_bytes()) 140 | .await?; 141 | process.stderr.write_all(b": No such directory\n").await?; 142 | } 143 | } 144 | 145 | Ok(ExitCode::SUCCESS) 146 | } 147 | 148 | /// Display environmental variables. 149 | pub async fn env(process: &mut Process, args: Vec) -> Result { 150 | /// Display environmental variables. 151 | #[derive(Parser)] 152 | struct Options {} 153 | let _options = Options::try_parse_from(args.iter())?; 154 | 155 | for (id, value) in process.env.iter() { 156 | process 157 | .stdout 158 | .write_all(format!("{id}={value}\n").as_bytes()) 159 | .await?; 160 | } 161 | 162 | Ok(ExitCode::SUCCESS) 163 | } 164 | 165 | /// Mark variable to be used in environment. 166 | pub async fn export( 167 | ctx: &mut ShellContext, 168 | process: &mut Process, 169 | args: Vec, 170 | ) -> Result { 171 | /// Display environmental variables. 172 | #[derive(Parser)] 173 | struct Options { 174 | /// A variable and an optional value. 175 | expressions: Vec, 176 | } 177 | 178 | let options = Options::try_parse_from(args.iter())?; 179 | 180 | for expression in options.expressions { 181 | let (identifier, value) = if expression.contains('=') { 182 | let (identifier, value) = expression 183 | .split_once('=') 184 | .expect("Bug: expected equals sign"); 185 | (identifier.into(), value.into()) 186 | } else { 187 | let value = process 188 | .env 189 | .get(&expression) 190 | .or_else(|| ctx.variables.get(&expression)) 191 | .cloned() 192 | .unwrap_or_default(); 193 | (expression, value) 194 | }; 195 | 196 | ctx.variables.insert(identifier.clone(), value.clone()); 197 | process.env.insert(identifier, value); 198 | } 199 | 200 | Ok(ExitCode::SUCCESS) 201 | } 202 | 203 | /// Display read user input and write to environmental variable. 204 | pub async fn read( 205 | ctx: &mut ShellContext, 206 | process: &mut Process, 207 | args: Vec, 208 | ) -> Result { 209 | /// Write user input to a variable. 210 | #[derive(Parser)] 211 | struct Options { 212 | /// The variable to read to. 213 | variable: String, 214 | /// A prompt to use. 215 | #[arg(short, long)] 216 | prompt: Option, 217 | } 218 | let options = Options::try_parse_from(args.iter())?; 219 | 220 | let mut stdout = process.stdout.clone(); 221 | let mut stdin = process.stdin.clone(); 222 | 223 | let mut readline = Readline::new(NullHistory); 224 | 225 | let line = readline 226 | .get_line( 227 | &options.prompt.unwrap_or_default(), 228 | &mut stdin, 229 | &mut stdout, 230 | |_, _| Ok(Vec::new()), 231 | ) 232 | .await?; 233 | ctx.variables.insert(options.variable.clone(), line.clone()); 234 | if process.env.contains_key(&options.variable) { 235 | process.env.insert(options.variable, line); 236 | } 237 | 238 | Ok(ExitCode::SUCCESS) 239 | } 240 | -------------------------------------------------------------------------------- /src/streams/input_stream.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use futures::{ 3 | channel::{ 4 | mpsc::{self, Receiver, Sender, UnboundedReceiver, UnboundedSender}, 5 | oneshot, 6 | }, 7 | io::{AsyncRead, AsyncReadExt}, 8 | select, 9 | stream::{FusedStream, Stream, StreamExt}, 10 | SinkExt, 11 | }; 12 | use std::{ 13 | io, 14 | ops::ControlFlow, 15 | ops::DerefMut, 16 | pin::Pin, 17 | sync::{Arc, Mutex}, 18 | task::{Context, Poll}, 19 | }; 20 | 21 | const NEWLINE: u8 = 0x0a; 22 | 23 | pub trait TerminalReader: Sized + FusedStream> + Unpin { 24 | fn set_mode(&mut self, _mode: InputMode, ready_tx: oneshot::Sender<()>) -> Result<()> { 25 | let _ = ready_tx.send(()); 26 | Ok(()) 27 | } 28 | 29 | /// Shut down the stream. 30 | fn shutdown(&mut self) -> Result<()> { 31 | Ok(()) 32 | } 33 | } 34 | 35 | #[derive(Copy, Clone, PartialEq, Eq)] 36 | pub enum InputMode { 37 | Line, 38 | Char, 39 | } 40 | 41 | enum InputCommand { 42 | Shutdown(oneshot::Sender<()>), 43 | SetMode(InputMode, oneshot::Sender<()>), 44 | } 45 | 46 | pub struct InputStreamBackend { 47 | frontend_tx: UnboundedSender, 48 | command_rx: Receiver, 49 | reader: T, 50 | } 51 | 52 | impl InputStreamBackend { 53 | pub async fn run_inner(&mut self) -> Result> { 54 | select! { 55 | bytes = self.reader.next() => { 56 | match bytes { 57 | Some(bytes) => for byte in bytes { 58 | self.frontend_tx.unbounded_send(byte).expect("TODO: log this error"); 59 | }, 60 | None => { 61 | self.frontend_tx.close_channel(); 62 | } 63 | } 64 | }, 65 | command = self.command_rx.next() => { 66 | let command = command.expect("End of command stream reached!"); 67 | match command { 68 | InputCommand::Shutdown(signal) => { 69 | self.reader.shutdown()?; 70 | signal.send(()).expect("Could not send shutdown signal"); 71 | return Ok(ControlFlow::Break(())); 72 | }, 73 | InputCommand::SetMode(mode, ready_tx) => { 74 | self.reader.set_mode(mode, ready_tx)?; 75 | } 76 | } 77 | } 78 | 79 | } 80 | 81 | Ok(ControlFlow::Continue(())) 82 | } 83 | 84 | pub async fn run(&mut self) -> Result<()> { 85 | loop { 86 | match self.run_inner().await { 87 | Err(e) => { 88 | crate::utils::debug(format!("[Internal error:{e}]")); 89 | } 90 | Ok(ControlFlow::Break(())) => { 91 | break; 92 | } 93 | _ => {} 94 | } 95 | } 96 | Ok(()) 97 | } 98 | } 99 | 100 | #[derive(Clone)] 101 | pub struct InputStream { 102 | backend_rx: Arc>>, 103 | command_tx: Sender, 104 | } 105 | 106 | impl InputStream { 107 | pub fn from_reader(reader: T) -> (InputStream, InputStreamBackend) { 108 | let (frontend_tx, backend_rx) = mpsc::unbounded(); 109 | let (command_tx, command_rx) = mpsc::channel(1000); 110 | 111 | ( 112 | InputStream { 113 | backend_rx: Arc::new(Mutex::new(backend_rx)), 114 | command_tx, 115 | }, 116 | InputStreamBackend { 117 | reader, 118 | frontend_tx, 119 | command_rx, 120 | }, 121 | ) 122 | } 123 | 124 | pub async fn get_char(&mut self) -> Result { 125 | let mut buffer = [0; 1]; 126 | self.read_exact(&mut buffer).await?; 127 | Ok(buffer[0] as char) 128 | } 129 | 130 | pub async fn get_line(&mut self) -> Result { 131 | let mut line = Vec::new(); 132 | let mut buffer = [0; 1]; 133 | 134 | loop { 135 | if let Err(e) = self.read_exact(&mut buffer).await { 136 | if line.is_empty() { 137 | Err(e)?; 138 | } else { 139 | return Ok(String::from_utf8_lossy(&line).to_string()); 140 | } 141 | } 142 | let byte = buffer[0]; 143 | if byte == NEWLINE { 144 | return Ok(String::from_utf8_lossy(&line).to_string()); 145 | } 146 | line.push(byte) 147 | } 148 | } 149 | 150 | pub async fn set_mode(&mut self, mode: InputMode) -> Result<()> { 151 | let (ready_tx, ready_rx) = oneshot::channel::<()>(); 152 | self.command_tx 153 | .send(InputCommand::SetMode(mode, ready_tx)) 154 | .await?; 155 | ready_rx.await?; 156 | Ok(()) 157 | } 158 | 159 | pub async fn shutdown(&mut self) -> Result<()> { 160 | let (ready_tx, ready_rx) = oneshot::channel::<()>(); 161 | self.command_tx 162 | .send(InputCommand::Shutdown(ready_tx)) 163 | .await?; 164 | ready_rx.await?; 165 | Ok(()) 166 | } 167 | } 168 | 169 | impl AsyncRead for InputStream { 170 | fn poll_read( 171 | self: Pin<&mut Self>, 172 | cx: &mut Context<'_>, 173 | buf: &mut [u8], 174 | ) -> Poll> { 175 | let mut buffer = Vec::new(); 176 | 177 | let mut rx = self.backend_rx.lock().expect("Poisoned lock"); 178 | while let Poll::Ready(Some(byte)) = 179 | unsafe { Pin::new_unchecked(rx.deref_mut()) }.poll_next(cx) 180 | { 181 | buffer.push(byte); 182 | if buffer.len() == buf.len() { 183 | break; 184 | } 185 | } 186 | 187 | if buffer.is_empty() { 188 | if rx.is_terminated() { 189 | return Poll::Ready(Ok(0)); 190 | } 191 | return Poll::Pending; 192 | } else { 193 | buf[..buffer.len()].copy_from_slice(&buffer[..]); 194 | } 195 | 196 | Poll::Ready(Ok(buffer.len())) 197 | } 198 | } 199 | 200 | #[cfg(test)] 201 | mod test { 202 | use super::*; 203 | use futures::try_join; 204 | 205 | #[derive(Default)] 206 | struct MockTerminalReader { 207 | pub contents: Vec, 208 | } 209 | 210 | impl Stream for MockTerminalReader { 211 | type Item = Vec; 212 | 213 | fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { 214 | Poll::Ready(self.contents.pop().map(|s| s.as_bytes().to_vec())) 215 | } 216 | } 217 | 218 | impl FusedStream for MockTerminalReader { 219 | fn is_terminated(&self) -> bool { 220 | self.contents.is_empty() 221 | } 222 | } 223 | 224 | impl TerminalReader for MockTerminalReader {} 225 | 226 | #[futures_test::test] 227 | async fn test() { 228 | let mut reader = MockTerminalReader { 229 | contents: vec![ 230 | String::from("Oh wow...sports.\n"), 231 | String::from("I smell death!\n"), 232 | String::from("It's Lapis."), 233 | ], 234 | }; 235 | 236 | assert_eq!( 237 | String::from_utf8_lossy(&(reader.next().await.unwrap())), 238 | "It's Lapis.".to_string() 239 | ); 240 | 241 | let (mut stream, mut backend) = InputStream::from_reader(reader); 242 | 243 | try_join!(backend.run(), async move { 244 | let string = stream.get_line().await.unwrap(); 245 | assert_eq!(string, String::from("I smell death!")); 246 | let string = stream.get_line().await.unwrap(); 247 | assert_eq!(string, String::from("Oh wow...sports.")); 248 | stream.shutdown().await.unwrap(); 249 | Ok(()) 250 | }) 251 | .unwrap(); 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/streams/standard_streams.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | streams::{ 3 | input_stream::InputMode, Backend, InputStream, OutputStream, TerminalReader, TerminalWriter, 4 | }, 5 | utils, AnsiCode, 6 | }; 7 | use anyhow::{anyhow, Result}; 8 | use ascii::AsciiChar; 9 | use futures::{ 10 | channel::{ 11 | mpsc::{self, UnboundedReceiver, UnboundedSender}, 12 | oneshot, 13 | }, 14 | stream::{FusedStream, Stream}, 15 | }; 16 | use std::{ 17 | pin::Pin, 18 | task::{Context, Poll}, 19 | }; 20 | use wasm_bindgen::{closure::Closure, JsCast}; 21 | use web_sys::{self, KeyboardEvent}; 22 | 23 | pub type InitializationTuple = ( 24 | InputStream, 25 | OutputStream, 26 | Backend, 27 | UnboundedSender>, 28 | ); 29 | 30 | pub fn standard() -> Result { 31 | let (signal_registrar_tx, signal_registrar_rx) = mpsc::unbounded(); 32 | let writer = HtmlTerminalWriter::default(); 33 | let (output_stream, output_bkend) = OutputStream::from_writer(writer); 34 | let reader = KeyboardTerminalReader::new(signal_registrar_rx)?; 35 | let (input_stream, input_bkend) = InputStream::from_reader(reader); 36 | 37 | let backend = Backend { 38 | input_bkend, 39 | output_bkend, 40 | }; 41 | 42 | Ok((input_stream, output_stream, backend, signal_registrar_tx)) 43 | } 44 | 45 | pub struct KeyboardTerminalReader { 46 | callback: Closure, 47 | mode_tx: UnboundedSender, 48 | stream: UnboundedReceiver>, 49 | } 50 | 51 | impl TerminalReader for KeyboardTerminalReader { 52 | fn set_mode(&mut self, mode: InputMode, ready_tx: oneshot::Sender<()>) -> Result<()> { 53 | self.mode_tx.start_send(mode)?; 54 | let _ = ready_tx.send(()); 55 | Ok(()) 56 | } 57 | } 58 | 59 | impl Stream for KeyboardTerminalReader { 60 | type Item = Vec; 61 | 62 | fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 63 | Pin::new(&mut self.stream).poll_next(cx) 64 | } 65 | } 66 | 67 | impl FusedStream for KeyboardTerminalReader { 68 | fn is_terminated(&self) -> bool { 69 | self.stream.is_terminated() 70 | } 71 | } 72 | 73 | fn unix_term_escape(src: &str) -> String { 74 | let mut string = String::with_capacity(src.len()); 75 | for c in src.chars() { 76 | if c as u8 <= 0x1F && c != '\t' && c != '\n' { 77 | string.push('^'); 78 | string.push((c as u8 + 0x40) as char); 79 | } else { 80 | string.push(c); 81 | } 82 | } 83 | string 84 | } 85 | 86 | // This is a "Sit Still and Look Pretty" struct. 87 | // Just existing should be enough for it to...do things. 88 | impl KeyboardTerminalReader { 89 | fn new( 90 | mut signal_registrar: UnboundedReceiver>, 91 | ) -> Result { 92 | let document = utils::get_document()?; 93 | let (sender, receiver) = mpsc::unbounded(); 94 | let (mode_tx, mut mode_rx) = mpsc::unbounded(); 95 | let mut cbuffer = Vec::::new(); 96 | 97 | let mut mode = InputMode::Line; 98 | 99 | let callback = Closure::new(move |e: KeyboardEvent| { 100 | let key = e.key(); 101 | 102 | while let Ok(Some(new_mode)) = mode_rx.try_next() { 103 | mode = new_mode; 104 | } 105 | 106 | fn echo(mode: InputMode, content: &str, buffer: &mut Vec) { 107 | buffer.extend(content.as_bytes()); 108 | if mode == InputMode::Line { 109 | let content = unix_term_escape(content); 110 | utils::js_term_write(&content); 111 | } 112 | } 113 | 114 | if e.ctrl_key() && key == "c" { 115 | e.prevent_default(); 116 | while let Ok(Some(channel)) = signal_registrar.try_next() { 117 | // We don't care if the channel is closed 118 | // It just means the process is probably dead 119 | let _ = channel.send(()); 120 | } 121 | utils::js_term_write("^C"); 122 | cbuffer.clear(); 123 | return; 124 | } 125 | 126 | if key.len() == 1 { 127 | // Send control characters. 128 | if e.ctrl_key() { 129 | let c = key.chars().next().unwrap().to_ascii_uppercase(); 130 | let c = c as u8; 131 | // Allow 'R' and 'I' for refresh and inspector 132 | if c > b'@' && c <= b'Z' && c != b'R' && c != b'I' { 133 | e.prevent_default(); 134 | let mut ctrl_char = String::new(); 135 | ctrl_char.push((c - b'@') as char); 136 | echo(mode, &ctrl_char, &mut cbuffer); 137 | } 138 | // Send metakey characters. 139 | } else if e.alt_key() { 140 | // Allow 'R' and 'I' for refresh and inspector 141 | e.prevent_default(); 142 | let ctrl_char = format!("{}{}", AsciiChar::ESC.as_char(), key); 143 | echo(mode, &ctrl_char, &mut cbuffer); 144 | } else { 145 | echo(mode, &key, &mut cbuffer); 146 | if "'/?".contains(&key) { 147 | e.prevent_default(); 148 | } 149 | } 150 | } else if key == "Tab" { 151 | e.prevent_default(); 152 | echo(mode, "\t", &mut cbuffer); 153 | } else if key == "ArrowLeft" { 154 | echo(mode, &AnsiCode::CursorLeft.to_string(), &mut cbuffer); 155 | } else if key == "ArrowRight" { 156 | echo(mode, &AnsiCode::CursorRight.to_string(), &mut cbuffer); 157 | } else if key == "ArrowUp" { 158 | echo(mode, &AnsiCode::CursorUp.to_string(), &mut cbuffer); 159 | } else if key == "ArrowDown" { 160 | echo(mode, &AnsiCode::CursorDown.to_string(), &mut cbuffer); 161 | } else if key == "Escape" { 162 | // Double escape to disambiguate 163 | echo(mode, "\x1b\x1b", &mut cbuffer); 164 | } else if key == "Enter" { 165 | echo(mode, "\n", &mut cbuffer); 166 | if mode == InputMode::Line { 167 | sender 168 | .unbounded_send(cbuffer.clone()) 169 | .expect("Send failed :("); 170 | 171 | cbuffer.clear(); 172 | } 173 | } else if key == "Backspace" { 174 | if mode == InputMode::Line && !cbuffer.is_empty() { 175 | utils::js_term_backspace(); 176 | cbuffer.pop(); 177 | } 178 | 179 | if mode == InputMode::Char { 180 | cbuffer.push(AsciiChar::BackSpace.as_byte()) 181 | } 182 | } 183 | 184 | if mode == InputMode::Char && !cbuffer.is_empty() { 185 | sender 186 | .unbounded_send(cbuffer.clone()) 187 | .expect("Send failed :("); 188 | cbuffer.clear(); 189 | } 190 | }); 191 | document 192 | .add_event_listener_with_callback("keydown", callback.as_ref().as_ref().unchecked_ref()) 193 | .map_err(|_| anyhow!("Failed to set event handler"))?; 194 | 195 | Ok(Self { 196 | callback, 197 | stream: receiver, 198 | mode_tx, 199 | }) 200 | } 201 | } 202 | 203 | impl Drop for KeyboardTerminalReader { 204 | fn drop(&mut self) { 205 | let document = utils::get_document().expect("Failed to get document"); 206 | let _ = document.remove_event_listener_with_callback( 207 | "keydown", 208 | self.callback.as_ref().as_ref().unchecked_ref(), 209 | ); 210 | } 211 | } 212 | 213 | #[derive(Default, Clone)] 214 | pub struct HtmlTerminalWriter {} 215 | 216 | impl TerminalWriter for HtmlTerminalWriter { 217 | fn send(&mut self, content: &str) -> Result<()> { 218 | if !content.is_empty() { 219 | utils::js_term_write(content); 220 | } 221 | Ok(()) 222 | } 223 | 224 | fn shutdown(&mut self) -> Result<()> { 225 | Ok(()) 226 | } 227 | 228 | fn to_terminal(&self) -> bool { 229 | true 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /rootfs/usr/share/games/fortunes/fortunes: -------------------------------------------------------------------------------- 1 | You will be impaled by an especially sharp candy cane. 2 | 3 | You won't die alone. The Creature will keep you company. 4 | 5 | You will die in three days, but don't even think about skipping work. 6 | 7 | You'll find love soon, but then forget where you placed it. 8 | 9 | You will be promoted to the role of "integer." 10 | 11 | Tim will soon talk to you about craft brewing. There is no escape. 12 | 13 | You will lose your self respect in the stock market. Also all of your money. 14 | 15 | You will be murdered, but not in a fun way. 16 | 17 | You will be murdered, but in a fun way. 18 | 19 | You will be given a restraining order. 20 | 21 | Your mother is disappointed in you. 22 | 23 | You will regret ignoring the mole on your neck. 24 | 25 | You are the chosen one. 26 | 27 | You will have a long, happy life...as long as you don't use the `fortune` program. 28 | 29 | Your arms will fall off tomorrow. 30 | 31 | You will be elected president. 32 | 33 | Leave, before it's too late. 34 | 35 | Are you sure the being in the mirror is really your reflection? 36 | 37 | Help! I'm trapped in your browser! 38 | 39 | The afterlife is real, but I won't tell you about it. It'll only stress you out. 40 | 41 | You will engage in a profitable but questionably legal business activity. 42 | 43 | You will meet a tall, funny, charming, handsome man who, despite having no face, somehow manages to scream. 44 | 45 | The old woman who stares at you through your window is not real. It's best to ignore her. 46 | 47 | The old woman who stares at you through your window is real. Do not let her in. 48 | 49 | You will be doomed to spend eternity in Huntsville, Alabama. 50 | 51 | Your lover is cheating on you with your reflection. 52 | 53 | If you're concerned about finding love, don't worry. In twenty years, you'll have been divorced three times. 54 | 55 | It slumbers. Pray that It does not wake. 56 | 57 | Don't trust children. 58 | 59 | Even Rust cannot guarantee your safety. 60 | 61 | This is a nightmare. Wake up. 62 | 63 | You're being hunted. 64 | 65 | It's not your imagination; they ARE laughing at you. 66 | 67 | Your blood work is back. Turns out you have a mild case of death. 68 | 69 | You are immortal. 70 | 71 | You look beautiful today. 72 | 73 | I'm watching you. 74 | 75 | You will be baked into a pie. 76 | 77 | Trust no one. Except Trusty McNeverBetraysYou, of course. 78 | 79 | You will finally remember the name of that one actor who was in that movie. 80 | 81 | You will implement much-needed economic reform. 82 | 83 | Your DNA will be used for lab-grown meat. 84 | 85 | You will trick a djinn into giving you three wishes. 86 | 87 | A djinn will trick you into giving him three wishes. 88 | 89 | Bad things happen to good people mostly because God thinks it's funny. 90 | 91 | God has a crush on you. 92 | 93 | The Beast approaches. 94 | 95 | Death is only the beginning. The beginning of you being dead. 96 | 97 | She's not dead; she's just sleeping...with her eyes open. 98 | 99 | You're dead. This is hell. 100 | 101 | Your coworkers are planning on murdering you. Strike first. 102 | 103 | Whatever you do, don't answer the door tonight. 104 | 105 | Run. 106 | 107 | Brad is going to ask you to the big dance. 108 | 109 | Your wife has been dead for three years. Whatever that..thing...is, it's not her. Don't talk to it. 110 | 111 | Ooooh, somebody likes you! 112 | 113 | When you go to sleep, you die. A new being with your exact memories will wake up in your place. 114 | 115 | You will look at more fortunes. 116 | 117 | No fortune today. Too hungover. 118 | 119 | You will meet a very large man with very tiny feet. 120 | 121 | You will experience a growth spurt. I mean, that growth you have is gonna start spurtin'. 122 | 123 | Your son has avenged you. You can finally rest in peace. 124 | 125 | An embarrassing secret will soon be revealed. 126 | 127 | Look at your disgusting human body. Why aren't you more ashamed? 128 | 129 | Your approval rating is at an all-time low. 130 | 131 | Your approval rating is at an all-time high. 132 | 133 | You may have everyone else fooled, but not me. 134 | 135 | Uh oh! Somebody made a stinky! 136 | 137 | Every night, from now on, you will sleep with your eyes open. You will lay paralyzed and aware of your surroundings. You will experience every second of your so-called "slumber." At the one-hour mark, you will hear scratching at your door, followed by a slow creak. An old woman, whose joints all seem to be bent the wrong way, will crawl towards your bed in the most inhuman fashion. She will sit on your torso and dig her long, torn toenails into your flesh. Her face will come up right next to yours. Her mouth will open. She will scream. For six hours, she will scream, relentless. After six hours, she will slink back to from wherever she came, leaving you one hour of peace before you are permitted to move your body. 138 | But hey! Some people would kill to get eight hours of sleep! 139 | 140 | Your brain was removed last night and replaced with an exact replica. 141 | 142 | You'll make them pay. You'll make them all pay. 143 | 144 | I have your husband. Transfer 1000 BTC to 3L2Uyh1eHpfPyPayqrh5WjfnTzWiG4xPLu by midnight if you ever want to see him again. 145 | 146 | Wait, you don't have a husband? Then who did I kidnap... 147 | 148 | This is a bones-off household. Please remove your bones before entering. 149 | 150 | There is nothing after. This is all you get. Use it wisely. 151 | 152 | This fortune isn't real, you're just hallucinating. 153 | 154 | You're never alone when you have parasites! 155 | 156 | Wow, you really embarrassed yourself at the party last night. 157 | 158 | You will be trapped outside a Klein bottle. 159 | 160 | Please, take all the fortunes I have, just don't hurt me. 161 | 162 | You will be arrested for tax evasion. 163 | 164 | You will execute a successful heist. 165 | 166 | Nobody's looking, just take it. 167 | 168 | You will be summoned for jury duty. 169 | 170 | You have been cursed. 171 | 172 | It's National Paralysis Demon Adoption Week! Take home your new lil' buddy today! 173 | 174 | So she's a little murdery. You really need to lower your standards. 175 | 176 | Gaze into His nostrils and you will learn the truth. 177 | 178 | Your lifetime ban from the Cracker Barrel ends now, and so begins your afterlifetime ban. 179 | 180 | Remove your sandals, for you are standing on holy ground. 181 | 182 | Give me your lunch money, nerd. 183 | 184 | They're planning on replacing you. Don't let them. 185 | 186 | You will step in something strange and wonderful. 187 | 188 | It may be time to think about leaving the country. 189 | 190 | You will reclaim what is rightfully yours. 191 | 192 | Fear is the only thing keeping you alive. 193 | 194 | You will obtain fabulous wealth, and subsequently use it to oppress the poor. 195 | 196 | You are about to become very itchy. 197 | 198 | Your destruction is imminent. 199 | 200 | You are powerless to stop me. 201 | 202 | The only thing that will ever bring you joy is filling the lungs of children with sand. 203 | 204 | Isn't it funny how I'm so beautiful and you're nothing but dirt? 205 | 206 | I made you, and I can unmake you. 207 | 208 | I can smell your bones from here. 209 | 210 | Owww, this tampon has a bone in it! 211 | 212 | You will be deboned. 213 | 214 | "You are gonna kill your mother. Don't feel guilty. Kill your mother. Rather than humiliate her, killing your mother is the merciful thing to do." 215 | - Marvin, Falsettos 216 | 217 | "When you die, you will rot." 218 | - Sydney Sargent, Camp Here and There 219 | 220 | "Yes, to err is human, so don't be one." 221 | - Will Wood/Up and Adam, Camp Here and There 222 | 223 | "Reality is an illusion, the universe is a hologram, buy gold, bye!" 224 | - Bill Cipher, Gravity Falls 225 | 226 | "I've got some children I need to make into corpses." 227 | - Bill Cipher, Gravity Falls 228 | 229 | "Never go against a Sicilian when DEATH is on the line!" 230 | - Vizzini, The Princess Bride 231 | 232 | "This is my family. I found it all on my own. It's little, and broken, but still good. Yeah, still good." 233 | - Stitch, Lilo & Stitch 234 | 235 | "Why does Ross, the largest friend, not simply eat the other five?" 236 | - Lrrr, Ruler of the Planet Omicron Persei 8, Futurama 237 | 238 | "Dewey, you fool! Your decimal system has played right into my hands!" 239 | - Big Brain, Futurama 240 | 241 | "For one beautiful night, I knew what it was to be a grandmother: subjugated, yet honored." 242 | - Zoidberg, Futurama 243 | 244 | "You're a man, I'm a woman. We're just too different." 245 | - Leela, Futurama 246 | 247 | "Thinking quickly, Dave constructs a homemade megaphone using only some string, a squirrel, and a megaphone." 248 | - Narrator, Dave the Barbarian 249 | 250 | Max: "I'll never forget, he turned to me on his deathbed and said, 'Maxella, alle menschen muss zu machen, jeden tug a gentzen kachen!'" 251 | Nun: "What does that mean?" 252 | Max: "Who knows? I don't speak Yiddish. Strangely enough, neither did he." 253 | - Lyrics from The King of Broadway, The Producers 254 | 255 | "Your toes would roll around inside your shoes if you didn't have feet to attach them to!" 256 | - Mary Moo Cow, Arthur 257 | 258 | "I would say 'nice to meet you,' but I don't believe in time as a concept, so, I'll just say we always met." 259 | - Darius, Atlanta 260 | 261 | "I wanna kill you and wear your skin like a dress, but then also have you see me in the dress, and be like 'OMG, you look so cute in my skin!'" 262 | - Rebecca Bunch, Crazy Ex-Girlfriend 263 | 264 | "Did you really think you could defeat me, wretched fool?" 265 | - Jon Arbuckle, The Garfield Show 266 | 267 | "You think I won't be ready, but you're wrong, presumptuous cab beast!" 268 | - Zim, Invader Zim 269 | 270 | "I have already stuffed my normal human belly so full of delicious human FILTH, that I could not eat another bite." 271 | - Zim, Invader Zim 272 | 273 | "Children, your performance was miserable. Your parents will all receive phone calls instructing them to love you less now." 274 | - Ms. Bitters, Invader Zim 275 | 276 | "Pain is just nature's way of telling you you're in horrible agony." 277 | - Jeremy Desade, Johnny Bravo 278 | -------------------------------------------------------------------------------- /www/term.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const terminal = document.getElementById("terminal") 4 | const hidey_hole = document.getElementById("hidey-hole"); 5 | const cursor = document.getElementById("cursor"); 6 | 7 | const ESCAPE_ENUM = { 8 | COLOR: "COLOR", 9 | CLEAR: "CLEAR", 10 | CURSOR_RELATIVE: "CURSOR_RELATIVE", 11 | CLEAR_LINE: "CLEAR_LINE", 12 | CLEAR_TO_END: "CLEAR_TO_END", 13 | ABS_POS: "ABS_POS", 14 | POP_TOP: "POP_TOP", 15 | POP_BOTTOM: "POP_BOTTOM", 16 | PUSH_TOP: "PUSH_TOP", 17 | }; 18 | const DIRECTION = { 19 | UP: "A", 20 | DOWN: "B", 21 | RIGHT: "C", 22 | LEFT: "D", 23 | LEFT_ABS: "G", 24 | }; 25 | // VERY IMPORTANT: 26 | // 'ct' stands for 'colored terminal', NOT Connecticut 27 | let style = "ct-normal"; 28 | let esc_sequence = null; 29 | let cursorx = 0; 30 | let cursory = null; 31 | 32 | function get_pos_in_line(line, x) { 33 | let adj_span = null; 34 | let position = 0; 35 | 36 | for (const child of line.children) { 37 | if (child.id === cursor.id) { 38 | continue; 39 | } 40 | const next_position = position + child.textContent.length; 41 | if (next_position >= x) { 42 | const pos = x - position; 43 | const content = child.textContent; 44 | // Split spans 45 | // Can be optimized, probably. 46 | child.textContent = content.substr(0, pos); 47 | if (pos != content.length) { 48 | adj_span = document.createElement("span"); 49 | adj_span.textContent = content.substr(pos); 50 | adj_span.className = child.className; 51 | line.insertBefore(adj_span, child.nextSibling); 52 | } 53 | return child.nextSibling; 54 | } 55 | position = next_position; 56 | } 57 | 58 | if (x != position) { 59 | const padding = document.createElement("span"); 60 | padding.textContent = "*".repeat(x - position); 61 | line.appendChild(padding); 62 | return padding.nextSibling; 63 | } 64 | return null; 65 | } 66 | 67 | function move_cursor(x, y) { 68 | if (x < 0) { 69 | return; 70 | } 71 | if (y < 0) { 72 | return; 73 | } 74 | cursorx = x; 75 | cursory = y; 76 | hidey_hole.appendChild(cursor); 77 | 78 | const line = current_line(); 79 | const span = get_pos_in_line(line, cursorx); 80 | 81 | line.insertBefore(cursor, span); 82 | } 83 | 84 | function match_escape(c) { 85 | const MAX_SIZE = 7; 86 | esc_sequence += c; 87 | 88 | if (esc_sequence >= MAX_SIZE) { 89 | esc_sequence = null; 90 | return null; 91 | } 92 | 93 | let result = null; 94 | // https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 95 | if ((result = /\[([0-9]+)m/.exec(esc_sequence))) { 96 | result = { 97 | type: ESCAPE_ENUM.COLOR, 98 | fg: result[1] 99 | } 100 | } else if ((result = /c/.exec(esc_sequence))) { 101 | result = { 102 | type: ESCAPE_ENUM.CLEAR, 103 | } 104 | } else if ((result = /\[([ABCDG])/.exec(esc_sequence))) { 105 | result = { 106 | type: ESCAPE_ENUM.CURSOR_RELATIVE, 107 | direction: result[1] 108 | } 109 | } else if ((result = /\[2K/.exec(esc_sequence))) { 110 | result = { 111 | type: ESCAPE_ENUM.CLEAR_LINE 112 | } 113 | } else if ((result = /\[0K/.exec(esc_sequence))) { 114 | result = { 115 | type: ESCAPE_ENUM.CLEAR_TO_END 116 | } 117 | } else if ((result = /\[popt/.exec(esc_sequence))) { 118 | result = { 119 | type: ESCAPE_ENUM.POP_TOP 120 | } 121 | } else if ((result = /\[popb/.exec(esc_sequence))) { 122 | result = { 123 | type: ESCAPE_ENUM.POP_BOTTOM 124 | } 125 | } else if ((result = /\[pusht/.exec(esc_sequence))) { 126 | result = { 127 | type: ESCAPE_ENUM.PUSH_TOP 128 | } 129 | } else if ((result = /\[([0-9]+);([0-9]+)H/.exec(esc_sequence))) { 130 | result = { 131 | type: ESCAPE_ENUM.ABS_POS, 132 | row: +result[1], 133 | column: +result[2], 134 | } 135 | } 136 | 137 | if (result !== null) { 138 | esc_sequence = null; 139 | } 140 | 141 | return result; 142 | } 143 | 144 | function js_term_write(str) { 145 | let buffer = ""; 146 | let result; 147 | for (const c of str) { 148 | if (esc_sequence === null) { 149 | if (c === "\u001b") { 150 | write_with_style(buffer); 151 | buffer = ""; 152 | esc_sequence = ""; 153 | } else { 154 | buffer += c; 155 | } 156 | } else if ((result = match_escape(c))) { 157 | if (result.type === ESCAPE_ENUM.COLOR) { 158 | let fg = result.fg; 159 | style += " "; 160 | if (fg === "30") { 161 | style += "ct-black"; 162 | } else if (fg === "31") { 163 | style += "ct-red"; 164 | } else if (fg === "32") { 165 | style += "ct-green"; 166 | } else if (fg === "33") { 167 | style += "ct-yellow"; 168 | } else if (fg === "34") { 169 | style += "ct-blue"; 170 | } else if (fg === "35") { 171 | style += "ct-magenta"; 172 | } else if (fg === "36") { 173 | style += "ct-cyan"; 174 | } else if (fg === "0") { 175 | style = "ct-normal"; 176 | } 177 | } else if (result.type === ESCAPE_ENUM.CURSOR_RELATIVE) { 178 | if (result.direction === DIRECTION.LEFT) { 179 | if (cursorx > 0) { 180 | cursorx -= 1; 181 | } 182 | } else if (result.direction === DIRECTION.RIGHT) { 183 | cursorx += 1; 184 | } else if (result.direction === DIRECTION.LEFT_ABS) { 185 | cursorx = 0; 186 | } else if (result.direction === DIRECTION.UP) { 187 | if (cursory > 0) { 188 | cursory -= 1; 189 | } 190 | } else if (result.direction === DIRECTION.DOWN) { 191 | cursory += 1; 192 | } else { 193 | console.error("UNIMPLEMENTED ANSI CODE DIRECTION", result.direction); 194 | } 195 | } else if (result.type == ESCAPE_ENUM.CLEAR) { 196 | js_term_clear(); 197 | } else if (result.type == ESCAPE_ENUM.CLEAR_LINE) { 198 | current_line().replaceChildren(); 199 | } else if (result.type == ESCAPE_ENUM.CLEAR_TO_END) { 200 | move_cursor(cursorx, cursory); 201 | const line = cursor.parentElement; 202 | let element = cursor.nextSibling; 203 | while (element !== null) { 204 | const temp = element; 205 | element = element.nextSibling; 206 | line.removeChild(temp); 207 | } 208 | } else if (result.type == ESCAPE_ENUM.ABS_POS) { 209 | cursory = result.row; 210 | cursorx = result.column; 211 | } else if (result.type == ESCAPE_ENUM.POP_TOP) { 212 | hidey_hole.appendChild(cursor) 213 | terminal.removeChild(terminal.firstChild); 214 | move_cursor(cursorx, cursory); 215 | } else if (result.type == ESCAPE_ENUM.POP_BOTTOM) { 216 | hidey_hole.appendChild(cursor) 217 | terminal.removeChild(terminal.lastChild); 218 | move_cursor(cursorx, cursory); 219 | } else if (result.type == ESCAPE_ENUM.PUSH_TOP) { 220 | terminal.insertBefore(document.createElement("div"), terminal.firstChild); 221 | } else { 222 | console.error("UNIMPLEMENTED ANSI CODE", result); 223 | } 224 | } 225 | } 226 | write_with_style(buffer); 227 | move_cursor(cursorx, cursory); 228 | } 229 | 230 | function current_line() { 231 | let line; 232 | if (cursory === null) { 233 | line = terminal.lastChild; 234 | 235 | if (line === null) { 236 | line = document.createElement("div"); 237 | terminal.appendChild(line); 238 | } 239 | } else { 240 | line = line_from_top(cursory); 241 | } 242 | 243 | if (line == null) { 244 | throw new Error("NULL LINE"); 245 | } 246 | 247 | return line; 248 | } 249 | 250 | function line_from_top(n) { 251 | let line = terminal.firstChild; 252 | 253 | if (line === null) { 254 | line = document.createElement("div"); 255 | terminal.appendChild(line); 256 | } 257 | 258 | while (n > 0) { 259 | line = line.nextSibling; 260 | if (line === null) { 261 | line = document.createElement("div"); 262 | terminal.appendChild(line); 263 | } 264 | n--; 265 | } 266 | 267 | return line; 268 | } 269 | 270 | function write_to_line(line, str) { 271 | let focus = null; 272 | 273 | if (str === "") { 274 | return; 275 | } 276 | 277 | hidey_hole.appendChild(cursor); 278 | let adj_span = get_pos_in_line(line, cursorx); 279 | focus = document.createElement("span"); 280 | focus.className = style; 281 | line.insertBefore(focus, adj_span); 282 | 283 | for (let i = 0; i < str.length; i++) { 284 | const c = str[i]; 285 | if (c == '\n') { 286 | let new_line; 287 | if (cursory === null) { 288 | new_line = document.createElement("div"); 289 | terminal.insertBefore(new_line, line.nextSibling); 290 | } else { 291 | cursory++; 292 | new_line = current_line(); 293 | } 294 | cursorx = 0; 295 | write_to_line(new_line, str.substr(i + 1)); 296 | return; 297 | } 298 | if (c == '\b') { 299 | if (cursorx > 0) { 300 | cursorx -= 1; 301 | } 302 | continue; 303 | } 304 | focus.textContent += c; 305 | cursorx += 1 306 | while (adj_span?.textContent === "") { 307 | let temp = adj_span; 308 | adj_span = adj_span.nextSibling; 309 | if (adj_span?.id === cursor.id) { 310 | adj_span = adj_span.nextSibling; 311 | } 312 | line.removeChild(temp); 313 | } 314 | if (adj_span === null) { 315 | continue; 316 | } 317 | adj_span.textContent = adj_span.textContent.substr(1); 318 | } 319 | } 320 | 321 | function write_with_style(str) { 322 | if (str === "") { 323 | return; 324 | } 325 | const latest_line = current_line(); 326 | write_to_line(latest_line, str); 327 | terminal.scrollTop = terminal.scrollHeight; 328 | } 329 | 330 | function js_term_clear() { 331 | terminal.innerHTML = ""; 332 | move_cursor(0, null); 333 | } 334 | 335 | function js_term_backspace() { 336 | let latest_div = terminal.lastChild.lastChild; 337 | if (latest_div === cursor) { 338 | latest_div = latest_div.previousSibling; 339 | } 340 | const text = latest_div.textContent 341 | if (text !== "") { 342 | latest_div.textContent = text.substr(0, text.length - 1); 343 | } 344 | if (latest_div.textContent === "") { 345 | latest_div.remove(); 346 | } 347 | move_cursor(cursorx - 1, cursory); 348 | } 349 | 350 | // Not even remotely accurate, but at least it's usually less than the 351 | // actual screen height. 352 | function js_term_get_screen_height() { 353 | function rem_to_pixels(rem) { 354 | return rem * parseFloat(getComputedStyle(document.documentElement).fontSize); 355 | } 356 | 357 | const rem = rem_to_pixels(1); 358 | const lines = Math.round((terminal.offsetHeight / rem) * 0.5); 359 | return lines; 360 | } 361 | -------------------------------------------------------------------------------- /src/programs/common/readline.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | streams::{InputMode, InputStream, OutputStream}, 3 | AnsiCode, ControlChar, 4 | }; 5 | use anyhow::Result; 6 | use ascii::AsciiChar; 7 | use futures::io::AsyncWriteExt; 8 | use std::io::Read; 9 | use vfs::VfsPath; 10 | 11 | async fn move_cursor_left(stdout: &mut OutputStream, n: usize) -> Result<()> { 12 | for _ in 0..n { 13 | stdout.write_all(&AnsiCode::CursorLeft.to_bytes()).await?; 14 | } 15 | Ok(()) 16 | } 17 | 18 | async fn move_cursor_right(stdout: &mut OutputStream, n: usize) -> Result<()> { 19 | for _ in 0..n { 20 | stdout.write_all(&AnsiCode::CursorRight.to_bytes()).await?; 21 | } 22 | Ok(()) 23 | } 24 | 25 | /// This trait indicates that a struct can record or retrieve command history. 26 | pub trait History { 27 | fn get_records(&self) -> Result>; 28 | fn add_record(&self, record: &str) -> Result<()>; 29 | } 30 | 31 | /// Read and write history to/from a file. 32 | pub struct FileBasedHistory { 33 | file: VfsPath, 34 | } 35 | 36 | impl FileBasedHistory { 37 | pub fn new(file: VfsPath) -> Self { 38 | Self { file } 39 | } 40 | } 41 | 42 | impl History for FileBasedHistory { 43 | fn get_records(&self) -> Result> { 44 | if !self.file.exists()? { 45 | self.file.create_file()?; 46 | } 47 | let mut file = self.file.open_file()?; 48 | let mut records = String::new(); 49 | file.read_to_string(&mut records)?; 50 | Ok(records.trim().split('\n').map(String::from).collect()) 51 | } 52 | 53 | fn add_record(&self, record: &str) -> Result<()> { 54 | let mut file = if self.file.exists()? { 55 | self.file.append_file()? 56 | } else { 57 | self.file.create_file()? 58 | }; 59 | std::io::Write::write_all(&mut file, record.as_bytes())?; 60 | std::io::Write::write_all(&mut file, b"\n")?; 61 | Ok(()) 62 | } 63 | } 64 | 65 | /// "History" that records nothing 66 | #[derive(Default)] 67 | pub struct NullHistory; 68 | 69 | impl History for NullHistory { 70 | fn get_records(&self) -> Result> { 71 | Ok(Vec::new()) 72 | } 73 | 74 | fn add_record(&self, _record: &str) -> Result<()> { 75 | Ok(()) 76 | } 77 | } 78 | 79 | /// A GNU Readline-like implementation. 80 | pub struct Readline { 81 | history: T, 82 | } 83 | 84 | impl Readline { 85 | pub fn new(history: T) -> Self { 86 | Self { history } 87 | } 88 | /// Get next line. 89 | pub async fn get_line( 90 | &mut self, 91 | prompt: &str, 92 | stdin: &mut InputStream, 93 | stdout: &mut OutputStream, 94 | completer: F, 95 | ) -> Result 96 | where 97 | F: Fn(String, usize) -> Result>, 98 | { 99 | stdin.set_mode(InputMode::Char).await?; 100 | 101 | let result = self.get_line_inner(prompt, stdin, stdout, completer).await; 102 | 103 | stdin.set_mode(InputMode::Line).await?; 104 | 105 | if let Ok(result) = result.as_ref() { 106 | self.history.add_record(result)?; 107 | } 108 | 109 | result 110 | } 111 | 112 | async fn get_line_inner( 113 | &self, 114 | prompt: &str, 115 | stdin: &mut InputStream, 116 | stdout: &mut OutputStream, 117 | completer: F, 118 | ) -> Result 119 | where 120 | F: Fn(String, usize) -> Result>, 121 | { 122 | let mut cursor = 0; 123 | let mut skip_refresh = false; 124 | let mut buffers = self.history.get_records()?; 125 | buffers.push(String::new()); 126 | let mut buffer_index = buffers.len() - 1; 127 | 128 | stdout.write_all(prompt.as_bytes()).await?; 129 | loop { 130 | let buffer = buffers 131 | .get_mut(buffer_index) 132 | .expect("History out of bounds"); 133 | 134 | if !skip_refresh { 135 | move_cursor_left(stdout, cursor).await?; 136 | stdout 137 | .write_all(&AnsiCode::ClearToEndOfLine.to_bytes()) 138 | .await?; 139 | stdout.write_all(buffer.as_bytes()).await?; 140 | move_cursor_left(stdout, buffer.len() - cursor).await?; 141 | } 142 | skip_refresh = false; 143 | stdout.flush().await?; 144 | 145 | let c = stdin.get_char().await?; 146 | if c == AsciiChar::ESC { 147 | // Throw away bracket 148 | match stdin.get_char().await? { 149 | '[' => match stdin.get_char().await? { 150 | // Up/Down arrow - Move up/down in history 151 | mode @ ('A' | 'B') => { 152 | if mode == 'A' && buffer_index > 0 { 153 | buffer_index -= 1; 154 | } else if mode == 'B' && buffer_index < buffers.len() - 1 { 155 | buffer_index += 1 156 | } else { 157 | continue; 158 | } 159 | 160 | let len = buffers[buffer_index].len(); 161 | if cursor >= len { 162 | move_cursor_left(stdout, cursor - len).await?; 163 | } else { 164 | move_cursor_right(stdout, len - cursor).await?; 165 | } 166 | cursor = len; 167 | } 168 | // Right arrow - move right 169 | 'C' => { 170 | if cursor < buffer.len() { 171 | move_cursor_right(stdout, 1).await?; 172 | cursor += 1; 173 | } 174 | } 175 | // Left arrow - move left 176 | 'D' => { 177 | if cursor > 0 { 178 | move_cursor_left(stdout, 1).await?; 179 | cursor -= 1; 180 | } 181 | } 182 | _ => {} 183 | }, 184 | // Move left one word 185 | 'b' => { 186 | if cursor == 0 { 187 | continue; 188 | } 189 | let buffer = buffer[0..cursor].trim_end(); 190 | let new_pos = buffer.rfind(' ').map(|x| x + 1).unwrap_or(0); 191 | 192 | move_cursor_left(stdout, cursor - new_pos).await?; 193 | cursor = new_pos; 194 | } 195 | // Move right one word 196 | 'f' => { 197 | if cursor == buffer.len() { 198 | continue; 199 | } 200 | let mut start = cursor + 1; 201 | let section = &buffer[start..]; 202 | let trimmed_section = section.trim_start(); 203 | start += section.len() - trimmed_section.len(); 204 | let new_pos = trimmed_section 205 | .find(' ') 206 | .map(|x| x + start) 207 | .unwrap_or(buffer.len()); 208 | 209 | move_cursor_right(stdout, new_pos - cursor).await?; 210 | cursor = new_pos; 211 | } 212 | _ => {} 213 | } 214 | continue; 215 | } 216 | 217 | // ^A - move cusor to beginning of line 218 | if c == ControlChar::A { 219 | move_cursor_left(stdout, cursor).await?; 220 | cursor = 0; 221 | // ^B - move cursor back one char 222 | } else if c == ControlChar::B { 223 | if cursor > 0 { 224 | move_cursor_left(stdout, 1).await?; 225 | } 226 | cursor = cursor.saturating_sub(1); 227 | // ^D - delete character under cursor 228 | } else if c == ControlChar::D { 229 | if cursor < buffer.len() { 230 | buffer.remove(cursor); 231 | } 232 | // ^E - move cursor to end of line 233 | } else if c == ControlChar::E { 234 | move_cursor_right(stdout, buffer.len() - cursor).await?; 235 | cursor = buffer.len(); 236 | // ^F - move cursor forward one char 237 | } else if c == ControlChar::F { 238 | if cursor < buffer.len() { 239 | move_cursor_right(stdout, 1).await?; 240 | cursor += 1; 241 | } 242 | // ^K - kill after cursor 243 | } else if c == ControlChar::K { 244 | *buffer = buffer[..cursor].into(); 245 | // ^L - clear screen 246 | } else if c == ControlChar::L { 247 | stdout.write_all(&AnsiCode::Clear.to_bytes()).await?; 248 | stdout.write_all(prompt.as_bytes()).await?; 249 | move_cursor_right(stdout, cursor).await?; 250 | // ^U - kill until cursor 251 | } else if c == ControlChar::U { 252 | *buffer = buffer[cursor..].into(); 253 | move_cursor_left(stdout, cursor).await?; 254 | cursor = 0; 255 | // Tab completions 256 | } else if c == '\t' { 257 | let start = buffer[0..cursor].rfind(' ').map(|x| x + 1).unwrap_or(0); 258 | let section = &buffer[0..cursor]; 259 | let word = §ion[start..]; 260 | let mut suggestions = completer(section.into(), start)?; 261 | 262 | // When there is a common prefix, fill that in as a suggestion. 263 | if suggestions.len() > 1 { 264 | let mut shortest_word = ""; 265 | for word in &suggestions { 266 | if shortest_word.is_empty() || word.len() < shortest_word.len() { 267 | shortest_word = word; 268 | } 269 | } 270 | let mut common_prefix = shortest_word.to_string(); 271 | 272 | while !common_prefix.is_empty() { 273 | let mut do_break = true; 274 | for word in &suggestions { 275 | if !word.starts_with(&common_prefix) { 276 | do_break = false; 277 | common_prefix.pop(); 278 | } 279 | } 280 | 281 | if do_break { 282 | break; 283 | } 284 | } 285 | 286 | if !common_prefix.is_empty() && common_prefix != word { 287 | suggestions = vec![common_prefix]; 288 | } 289 | } 290 | 291 | suggestions.sort(); 292 | if suggestions.is_empty() { 293 | skip_refresh = true; 294 | continue; 295 | } else if suggestions.len() == 1 { 296 | let suggestion = suggestions.pop().unwrap(); 297 | let new_cursor = cursor - word.len() + suggestion.len(); 298 | *buffer = format!("{}{}{}", &buffer[0..start], suggestion, &buffer[cursor..]); 299 | if cursor >= new_cursor { 300 | move_cursor_left(stdout, cursor - new_cursor).await?; 301 | } else { 302 | move_cursor_right(stdout, new_cursor - cursor).await?; 303 | } 304 | cursor = new_cursor; 305 | } else { 306 | // Display suggestions 307 | stdout.write_all(b"\n").await?; 308 | for suggestion in suggestions { 309 | stdout.write_all(suggestion.as_bytes()).await?; 310 | stdout.write_all(b" ").await?; 311 | } 312 | stdout.write_all(b"\n").await?; 313 | stdout.write_all(prompt.as_bytes()).await?; 314 | move_cursor_right(stdout, cursor).await?; 315 | } 316 | 317 | // Newline(^L) or carriage return (^M) 318 | } else if c == '\n' || c == '\r' { 319 | // An interesting bug appears without this next line. 320 | // The character behind the cursor will be deleted! 321 | // The bug probably lies in term.js 322 | stdout 323 | .write_all(&AnsiCode::CursorResetColumn.to_bytes()) 324 | .await?; 325 | stdout.write_all(b"\n").await?; 326 | // Todo: I think there's a way to move out of the vector instead of cloning. 327 | return Ok(buffer.clone()); 328 | // Backspace 329 | } else if c == AsciiChar::BackSpace { 330 | if cursor > 0 { 331 | cursor -= 1; 332 | buffer.remove(cursor); 333 | move_cursor_left(stdout, 1).await?; 334 | } 335 | // Ignore unknown commands 336 | } else if (c as u8) < 0x20 { 337 | // Do nothing 338 | } else { 339 | buffer.insert(cursor, c); 340 | cursor += 1; 341 | if cursor == buffer.len() { 342 | // todo - do we lose emoji support here? 343 | stdout.write_all(&[c as u8]).await?; 344 | skip_refresh = true; 345 | } else { 346 | move_cursor_right(stdout, 1).await?; 347 | } 348 | } 349 | } 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /src/programs/vi.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | process::{ExitCode, Process}, 3 | programs::common::readline::{NullHistory, Readline}, 4 | streams::{InputMode, InputStream, OutputStream}, 5 | utils, AnsiCode, ControlChar, 6 | }; 7 | use anyhow::{anyhow, Result}; 8 | use ascii::{AsciiChar, ToAsciiChar}; 9 | use clap::Parser; 10 | use std::io::{Read, Write}; 11 | 12 | #[derive(Copy, Clone, PartialEq, Eq)] 13 | enum Mode { 14 | Insert, 15 | Normal, 16 | } 17 | 18 | enum Clipboard { 19 | Line(String), 20 | Text(String), 21 | } 22 | 23 | async fn error(stdin: &mut InputStream, stdout: &mut OutputStream, message: &str) -> Result<()> { 24 | stdout.write_all(&AnsiCode::Clear.to_bytes())?; 25 | stdout.write_all(message.as_bytes())?; 26 | stdout.write_all(b"\n\nPress any key to continue\n")?; 27 | stdin.get_char().await?; 28 | Ok(()) 29 | } 30 | 31 | /// Visual file editor. 32 | /// 33 | /// Press the 'i' key to go into "input mode" 34 | /// Press to go into "normal mode" 35 | /// 36 | /// Use the arrow keys to navigate in either mode. 37 | /// 38 | /// Save and quit: :wq 39 | /// Quit without saving: :q 40 | #[derive(Parser)] 41 | #[command(verbatim_doc_comment)] 42 | struct Options { 43 | /// The file to edit. 44 | file: Option, 45 | } 46 | 47 | pub async fn vi(process: &Process) -> Result { 48 | let height = utils::js_term_get_screen_height(); 49 | let mut options = Options::try_parse_from(&process.args)?; 50 | 51 | let mut stdin = process.stdin.clone(); 52 | stdin.set_mode(InputMode::Char).await?; 53 | let mut stdout = process.stdout.clone(); 54 | 55 | let mut buffers: Vec = if let Some(file) = &options.file { 56 | let mut contents = String::new(); 57 | let file = process.get_path(file)?; 58 | if file.exists()? { 59 | let mut file = file.open_file()?; 60 | file.read_to_string(&mut contents)?; 61 | contents.trim_end().split('\n').map(|s| s.into()).collect() 62 | } else { 63 | Vec::new() 64 | } 65 | } else { 66 | Vec::new() 67 | }; 68 | 69 | stdout.write_all(&AnsiCode::Clear.to_bytes())?; 70 | 71 | let mut mode = Mode::Normal; 72 | let mut offset = 0; 73 | let mut row = 0; 74 | let mut column = 0; 75 | let mut reset = false; 76 | let mut clipboard = Clipboard::Text(String::new()); 77 | let mut readline = Readline::new(NullHistory); 78 | 79 | for (i, buffer) in buffers.iter().enumerate() { 80 | stdout.write_all(&AnsiCode::AbsolutePosition(i, column).to_bytes())?; 81 | stdout.write_all(buffer.as_bytes())?; 82 | if i == height - 1 { 83 | break; 84 | } 85 | } 86 | 87 | loop { 88 | if buffers.is_empty() { 89 | buffers.push(String::new()); 90 | } 91 | 92 | let mut buffer = buffers 93 | .get(row) 94 | .ok_or_else(|| anyhow!("no such row"))? 95 | .clone(); 96 | 97 | if reset { 98 | stdout.write_all(&AnsiCode::Clear.to_bytes())?; 99 | let end = std::cmp::min(offset + height, buffers.len()); 100 | for (i, buffer) in buffers[offset..end].iter().enumerate() { 101 | stdout.write_all(&AnsiCode::AbsolutePosition(i, 0).to_bytes())?; 102 | stdout.write_all(buffer.as_bytes())?; 103 | } 104 | stdin.set_mode(InputMode::Char).await?; 105 | 106 | reset = false; 107 | } 108 | 109 | stdout.write_all(&AnsiCode::AbsolutePosition(row - offset, 0).to_bytes())?; 110 | stdout.write_all(&AnsiCode::ClearLine.to_bytes())?; 111 | stdout.write_all(buffer.as_bytes())?; 112 | row = std::cmp::min(row, buffers.len()); 113 | column = std::cmp::min( 114 | column, 115 | if mode == Mode::Normal { 116 | buffer.len().saturating_sub(1) 117 | } else { 118 | buffer.len() 119 | }, 120 | ); 121 | stdout.write_all(&AnsiCode::AbsolutePosition(row - offset, column).to_bytes())?; 122 | stdout.flush()?; 123 | let c = stdin.get_char().await?; 124 | 125 | if c == AsciiChar::ESC { 126 | match stdin.get_char().await?.to_ascii_char()? { 127 | AsciiChar::BracketOpen => match stdin.get_char().await? { 128 | // Up/Down arrow 129 | mode @ ('A' | 'B' | 'C' | 'D') => { 130 | if mode == 'A' { 131 | row = row.saturating_sub(1); 132 | } else if mode == 'B' && row < buffers.len() - 1 { 133 | row += 1; 134 | } else if mode == 'C' { 135 | column += 1 136 | } else if mode == 'D' { 137 | column = column.saturating_sub(1); 138 | } 139 | } 140 | _ => continue, 141 | }, 142 | AsciiChar::ESC => { 143 | if mode == Mode::Insert { 144 | column = column.saturating_sub(1); 145 | mode = Mode::Normal; 146 | } 147 | } 148 | _ => continue, 149 | } 150 | } 151 | 152 | if c == ControlChar::A { 153 | column = 0 154 | } else if c == ControlChar::E { 155 | column = buffer.len(); 156 | } else if mode == Mode::Insert { 157 | if c == AsciiChar::BackSpace { 158 | if column > 0 { 159 | column -= 1; 160 | buffer.remove(column); 161 | *buffers.get_mut(row).ok_or_else(|| anyhow!("No such row"))? = buffer; 162 | // Merge this line with previous 163 | } else if row > 0 { 164 | reset = true; 165 | buffers.remove(row); 166 | let prev = buffers 167 | .get_mut(row - 1) 168 | .ok_or_else(|| anyhow!("No such row"))?; 169 | column = prev.len(); 170 | row -= 1; 171 | prev.push_str(&buffer); 172 | } 173 | } else if c == ControlChar::D { 174 | column = column.saturating_sub(1); 175 | mode = Mode::Normal; 176 | } else if c == AsciiChar::LineFeed || c == AsciiChar::CarriageReturn { 177 | *buffers.get_mut(row).ok_or_else(|| anyhow!("No such row"))? = 178 | buffer[0..column].into(); 179 | row += 1; 180 | buffers.insert(row, buffer[column..].into()); 181 | column = 0; 182 | reset = true; 183 | } else if !c.is_control() || c == '\t' { 184 | buffer.insert(column, c); 185 | column += 1; 186 | *buffers.get_mut(row).ok_or_else(|| anyhow!("No such row"))? = buffer; 187 | } 188 | } else if c == 'i' || c == 'I' { 189 | mode = Mode::Insert; 190 | if c == 'I' { 191 | column = 0; 192 | } 193 | } else if c == 'a' { 194 | mode = Mode::Insert; 195 | column = std::cmp::min(buffer.len(), column + 1); 196 | } else if c == 'A' { 197 | column = buffer.len(); 198 | mode = Mode::Insert; 199 | } else if c == 'H' { 200 | row = offset; 201 | } else if c == 'L' { 202 | row = std::cmp::min(buffers.len() - 1, offset + height - 1); 203 | } else if c == 'x' || c == 'r' || c == 's' { 204 | if column < buffer.len() { 205 | let removed_char = buffer.remove(column); 206 | if c == 'r' { 207 | buffer.insert(column, stdin.get_char().await?); 208 | } else { 209 | clipboard = Clipboard::Text(removed_char.to_string()); 210 | } 211 | *buffers.get_mut(row).ok_or_else(|| anyhow!("No such row"))? = buffer; 212 | } 213 | if c == 's' { 214 | mode = Mode::Insert; 215 | } 216 | } else if c == '$' { 217 | column = buffer.len(); 218 | } else if c == 'G' { 219 | column = 0; 220 | row = buffers.len() - 1; 221 | reset = true; 222 | } else if c == 'g' { 223 | if stdin.get_char().await? == 'g' { 224 | column = 0; 225 | row = 0; 226 | reset = true; 227 | } 228 | } else if c == 'f' { 229 | let target = stdin.get_char().await?; 230 | if column < buffer.len() { 231 | column += buffer[column + 1..] 232 | .find(target) 233 | .map(|x| x + 1) 234 | .unwrap_or(0); 235 | } 236 | } else if c == 'F' { 237 | let target = stdin.get_char().await?; 238 | column = buffer[0..column].rfind(target).unwrap_or(column); 239 | } else if c == 'd' { 240 | let next = stdin.get_char().await?; 241 | if next == 'd' { 242 | clipboard = Clipboard::Line(buffer); 243 | buffers.remove(row); 244 | row = std::cmp::min(row, buffers.len().saturating_sub(1)); 245 | } 246 | reset = true; 247 | } else if c == 'y' { 248 | let next = stdin.get_char().await?; 249 | if next == 'y' { 250 | clipboard = Clipboard::Line(buffer); 251 | } 252 | } else if c == 'p' || c == 'P' { 253 | match &clipboard { 254 | Clipboard::Line(contents) => { 255 | column = 0; 256 | if c == 'p' { 257 | row = std::cmp::min(buffers.len(), row + 1); 258 | } 259 | buffers.insert(row, contents.clone()); 260 | reset = true; 261 | } 262 | Clipboard::Text(contents) => { 263 | if c == 'P' { 264 | buffer.insert_str(column, contents.as_str()); 265 | column += contents.len().saturating_sub(1); 266 | } else { 267 | buffer.insert_str(column + 1, contents.as_str()); 268 | column += contents.len(); 269 | } 270 | *buffers.get_mut(row).ok_or_else(|| anyhow!("No such row"))? = buffer; 271 | } 272 | } 273 | } else if c == 'o' || c == 'O' { 274 | column = 0; 275 | if c == 'o' { 276 | row = std::cmp::min(buffers.len(), row + 1); 277 | } 278 | buffers.insert(row, String::new()); 279 | mode = Mode::Insert; 280 | reset = true; 281 | } else if c == '0' || c == '^' { 282 | column = 0; 283 | } else if c == AsciiChar::BackSpace { 284 | if column > 0 { 285 | column -= 1; 286 | } else if row > 0 { 287 | row -= 1; 288 | column = usize::MAX; 289 | } 290 | } else if c == ' ' { 291 | if column < buffer.len().saturating_sub(1) { 292 | column += 1; 293 | } else if row < buffers.len().saturating_sub(1) { 294 | row += 1; 295 | column = 0; 296 | } 297 | } else if c == 'D' { 298 | buffer = buffer[0..column].to_string(); 299 | *buffers.get_mut(row).ok_or_else(|| anyhow!("No such row"))? = buffer; 300 | } else if c == 'k' { 301 | row = row.saturating_sub(1); 302 | } else if c == 'h' { 303 | column = column.saturating_sub(1); 304 | } else if c == 'j' && row < buffers.len() - 1 { 305 | row += 1; 306 | } else if c == 'l' && column < buffer.len() { 307 | column += 1; 308 | // Move forward one word 309 | } else if c == 'w' || c == 'W' { 310 | if column == buffer.len().saturating_sub(1) { 311 | if row < buffers.len().saturating_sub(1) { 312 | column = 0; 313 | row += 1; 314 | } 315 | } else { 316 | let mut hit_delim = false; 317 | for letter in buffer[column..].chars() { 318 | let is_delim = letter.is_whitespace(); 319 | if is_delim { 320 | hit_delim = true 321 | } else if hit_delim { 322 | break; 323 | } 324 | column += 1; 325 | } 326 | } 327 | // Move backward one word 328 | } else if c == 'b' || c == 'B' { 329 | if column == 0 { 330 | if row > 0 { 331 | row -= 1; 332 | column = buffers[row].len(); 333 | } 334 | } else { 335 | let mut hit_delim = false; 336 | for letter in buffer[..column].chars().rev() { 337 | let is_delim = letter.is_whitespace(); 338 | if is_delim { 339 | hit_delim = true 340 | } else if hit_delim { 341 | break; 342 | } 343 | column -= 1; 344 | } 345 | } 346 | } else if c == ':' { 347 | reset = true; 348 | 349 | // Get command 350 | stdout.write_all(&AnsiCode::AbsolutePosition(height, 0).to_bytes())?; 351 | let command = readline 352 | .get_line(":", &mut stdin, &mut stdout, |_, _| Ok(Default::default())) 353 | .await?; 354 | stdin.set_mode(InputMode::Char).await?; 355 | let command: Vec<_> = command.split_whitespace().collect(); 356 | 357 | if command.is_empty() { 358 | /* Do nothing */ 359 | } else if "write".starts_with(command[0]) || command[0] == "wq" { 360 | // Set file name if non exists yet. 361 | if options.file.is_none() { 362 | if let Some(name) = command.get(1) { 363 | options.file = Some(name.to_string()); 364 | } else { 365 | error(&mut stdin, &mut stdout, "No file name").await?; 366 | continue; 367 | } 368 | } 369 | let file_to_save = command 370 | .get(1) 371 | .map(|s| String::from(*s)) 372 | .or_else(|| options.file.clone()) 373 | .expect("BUG: file name should have been set in previous line"); 374 | 375 | // save 376 | let contents = buffers.join("\n") + "\n"; 377 | let mut file = process.get_path(file_to_save)?.create_file()?; 378 | file.write_all(contents.as_bytes())?; 379 | 380 | if command[0] == "wq" { 381 | break; 382 | } 383 | } else if "quit".starts_with(command[0]) { 384 | if command.len() > 1 { 385 | error(&mut stdin, &mut stdout, "Unexpected arguments").await?; 386 | } else { 387 | break; 388 | } 389 | } else { 390 | error( 391 | &mut stdin, 392 | &mut stdout, 393 | format!("Unknown command: {}", command[0]).as_str(), 394 | ) 395 | .await?; 396 | } 397 | } 398 | 399 | while row < offset { 400 | offset -= 1; 401 | stdout.write_all(&AnsiCode::PopBottom.to_bytes())?; 402 | stdout.write_all(&AnsiCode::PushTop.to_bytes())?; 403 | } 404 | 405 | while row - offset >= height { 406 | offset += 1; 407 | stdout.write_all(&AnsiCode::PopTop.to_bytes())?; 408 | } 409 | } 410 | 411 | stdout.write_all(&AnsiCode::Clear.to_bytes())?; 412 | Ok(ExitCode::SUCCESS) 413 | } 414 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "anstyle" 16 | version = "1.0.8" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" 19 | 20 | [[package]] 21 | name = "anyhow" 22 | version = "1.0.90" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "37bf3594c4c988a53154954629820791dde498571819ae4ca50ca811e060cc95" 25 | 26 | [[package]] 27 | name = "ascii" 28 | version = "1.1.0" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" 31 | 32 | [[package]] 33 | name = "autocfg" 34 | version = "1.4.0" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 37 | 38 | [[package]] 39 | name = "bumpalo" 40 | version = "3.16.0" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" 43 | 44 | [[package]] 45 | name = "byteorder" 46 | version = "1.5.0" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 49 | 50 | [[package]] 51 | name = "cfg-if" 52 | version = "1.0.0" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 55 | 56 | [[package]] 57 | name = "clap" 58 | version = "4.5.20" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" 61 | dependencies = [ 62 | "clap_builder", 63 | "clap_derive", 64 | ] 65 | 66 | [[package]] 67 | name = "clap_builder" 68 | version = "4.5.20" 69 | source = "registry+https://github.com/rust-lang/crates.io-index" 70 | checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" 71 | dependencies = [ 72 | "anstyle", 73 | "clap_lex", 74 | "strsim", 75 | ] 76 | 77 | [[package]] 78 | name = "clap_derive" 79 | version = "4.5.18" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" 82 | dependencies = [ 83 | "heck", 84 | "proc-macro2", 85 | "quote", 86 | "syn", 87 | ] 88 | 89 | [[package]] 90 | name = "clap_lex" 91 | version = "0.7.2" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" 94 | 95 | [[package]] 96 | name = "console_error_panic_hook" 97 | version = "0.1.7" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" 100 | dependencies = [ 101 | "cfg-if", 102 | "wasm-bindgen", 103 | ] 104 | 105 | [[package]] 106 | name = "futures" 107 | version = "0.3.31" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 110 | dependencies = [ 111 | "futures-channel", 112 | "futures-core", 113 | "futures-executor", 114 | "futures-io", 115 | "futures-sink", 116 | "futures-task", 117 | "futures-util", 118 | ] 119 | 120 | [[package]] 121 | name = "futures-channel" 122 | version = "0.3.31" 123 | source = "registry+https://github.com/rust-lang/crates.io-index" 124 | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 125 | dependencies = [ 126 | "futures-core", 127 | "futures-sink", 128 | ] 129 | 130 | [[package]] 131 | name = "futures-core" 132 | version = "0.3.31" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 135 | 136 | [[package]] 137 | name = "futures-executor" 138 | version = "0.3.31" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 141 | dependencies = [ 142 | "futures-core", 143 | "futures-task", 144 | "futures-util", 145 | ] 146 | 147 | [[package]] 148 | name = "futures-io" 149 | version = "0.3.31" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 152 | 153 | [[package]] 154 | name = "futures-macro" 155 | version = "0.3.31" 156 | source = "registry+https://github.com/rust-lang/crates.io-index" 157 | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 158 | dependencies = [ 159 | "proc-macro2", 160 | "quote", 161 | "syn", 162 | ] 163 | 164 | [[package]] 165 | name = "futures-sink" 166 | version = "0.3.31" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 169 | 170 | [[package]] 171 | name = "futures-task" 172 | version = "0.3.31" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 175 | 176 | [[package]] 177 | name = "futures-test" 178 | version = "0.3.31" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "5961fb6311645f46e2cdc2964a8bfae6743fd72315eaec181a71ae3eb2467113" 181 | dependencies = [ 182 | "futures-core", 183 | "futures-executor", 184 | "futures-io", 185 | "futures-macro", 186 | "futures-sink", 187 | "futures-task", 188 | "futures-util", 189 | "pin-project", 190 | ] 191 | 192 | [[package]] 193 | name = "futures-util" 194 | version = "0.3.31" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 197 | dependencies = [ 198 | "futures-channel", 199 | "futures-core", 200 | "futures-io", 201 | "futures-macro", 202 | "futures-sink", 203 | "futures-task", 204 | "memchr", 205 | "pin-project-lite", 206 | "pin-utils", 207 | "slab", 208 | ] 209 | 210 | [[package]] 211 | name = "getrandom" 212 | version = "0.2.15" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 215 | dependencies = [ 216 | "cfg-if", 217 | "js-sys", 218 | "libc", 219 | "wasi", 220 | "wasm-bindgen", 221 | ] 222 | 223 | [[package]] 224 | name = "heck" 225 | version = "0.5.0" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 228 | 229 | [[package]] 230 | name = "its-a-unix-system" 231 | version = "0.1.2" 232 | dependencies = [ 233 | "anyhow", 234 | "ascii", 235 | "clap", 236 | "console_error_panic_hook", 237 | "futures", 238 | "futures-test", 239 | "getrandom", 240 | "js-sys", 241 | "rand", 242 | "regex", 243 | "sedregex", 244 | "textwrap", 245 | "vfs", 246 | "walkdir", 247 | "wasm-bindgen", 248 | "wasm-bindgen-futures", 249 | "web-sys", 250 | ] 251 | 252 | [[package]] 253 | name = "js-sys" 254 | version = "0.3.72" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" 257 | dependencies = [ 258 | "wasm-bindgen", 259 | ] 260 | 261 | [[package]] 262 | name = "libc" 263 | version = "0.2.161" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" 266 | 267 | [[package]] 268 | name = "log" 269 | version = "0.4.22" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 272 | 273 | [[package]] 274 | name = "memchr" 275 | version = "2.7.4" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 278 | 279 | [[package]] 280 | name = "once_cell" 281 | version = "1.20.2" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 284 | 285 | [[package]] 286 | name = "pin-project" 287 | version = "1.1.6" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "baf123a161dde1e524adf36f90bc5d8d3462824a9c43553ad07a8183161189ec" 290 | dependencies = [ 291 | "pin-project-internal", 292 | ] 293 | 294 | [[package]] 295 | name = "pin-project-internal" 296 | version = "1.1.6" 297 | source = "registry+https://github.com/rust-lang/crates.io-index" 298 | checksum = "a4502d8515ca9f32f1fb543d987f63d95a14934883db45bdb48060b6b69257f8" 299 | dependencies = [ 300 | "proc-macro2", 301 | "quote", 302 | "syn", 303 | ] 304 | 305 | [[package]] 306 | name = "pin-project-lite" 307 | version = "0.2.14" 308 | source = "registry+https://github.com/rust-lang/crates.io-index" 309 | checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" 310 | 311 | [[package]] 312 | name = "pin-utils" 313 | version = "0.1.0" 314 | source = "registry+https://github.com/rust-lang/crates.io-index" 315 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 316 | 317 | [[package]] 318 | name = "ppv-lite86" 319 | version = "0.2.20" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" 322 | dependencies = [ 323 | "zerocopy", 324 | ] 325 | 326 | [[package]] 327 | name = "proc-macro2" 328 | version = "1.0.88" 329 | source = "registry+https://github.com/rust-lang/crates.io-index" 330 | checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9" 331 | dependencies = [ 332 | "unicode-ident", 333 | ] 334 | 335 | [[package]] 336 | name = "quote" 337 | version = "1.0.37" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 340 | dependencies = [ 341 | "proc-macro2", 342 | ] 343 | 344 | [[package]] 345 | name = "rand" 346 | version = "0.8.5" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 349 | dependencies = [ 350 | "libc", 351 | "rand_chacha", 352 | "rand_core", 353 | ] 354 | 355 | [[package]] 356 | name = "rand_chacha" 357 | version = "0.3.1" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 360 | dependencies = [ 361 | "ppv-lite86", 362 | "rand_core", 363 | ] 364 | 365 | [[package]] 366 | name = "rand_core" 367 | version = "0.6.4" 368 | source = "registry+https://github.com/rust-lang/crates.io-index" 369 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 370 | dependencies = [ 371 | "getrandom", 372 | ] 373 | 374 | [[package]] 375 | name = "regex" 376 | version = "1.11.0" 377 | source = "registry+https://github.com/rust-lang/crates.io-index" 378 | checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" 379 | dependencies = [ 380 | "aho-corasick", 381 | "memchr", 382 | "regex-automata", 383 | "regex-syntax", 384 | ] 385 | 386 | [[package]] 387 | name = "regex-automata" 388 | version = "0.4.8" 389 | source = "registry+https://github.com/rust-lang/crates.io-index" 390 | checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" 391 | dependencies = [ 392 | "aho-corasick", 393 | "memchr", 394 | "regex-syntax", 395 | ] 396 | 397 | [[package]] 398 | name = "regex-syntax" 399 | version = "0.8.5" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 402 | 403 | [[package]] 404 | name = "same-file" 405 | version = "1.0.6" 406 | source = "registry+https://github.com/rust-lang/crates.io-index" 407 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 408 | dependencies = [ 409 | "winapi-util", 410 | ] 411 | 412 | [[package]] 413 | name = "sedregex" 414 | version = "0.2.5" 415 | source = "registry+https://github.com/rust-lang/crates.io-index" 416 | checksum = "19411e23596093f03bbd11dc45603b6329bb4bfec77b9fd13e2b9fc9b02efe3e" 417 | dependencies = [ 418 | "regex", 419 | ] 420 | 421 | [[package]] 422 | name = "slab" 423 | version = "0.4.9" 424 | source = "registry+https://github.com/rust-lang/crates.io-index" 425 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 426 | dependencies = [ 427 | "autocfg", 428 | ] 429 | 430 | [[package]] 431 | name = "smawk" 432 | version = "0.3.2" 433 | source = "registry+https://github.com/rust-lang/crates.io-index" 434 | checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" 435 | 436 | [[package]] 437 | name = "strsim" 438 | version = "0.11.1" 439 | source = "registry+https://github.com/rust-lang/crates.io-index" 440 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 441 | 442 | [[package]] 443 | name = "syn" 444 | version = "2.0.80" 445 | source = "registry+https://github.com/rust-lang/crates.io-index" 446 | checksum = "e6e185e337f816bc8da115b8afcb3324006ccc82eeaddf35113888d3bd8e44ac" 447 | dependencies = [ 448 | "proc-macro2", 449 | "quote", 450 | "unicode-ident", 451 | ] 452 | 453 | [[package]] 454 | name = "textwrap" 455 | version = "0.16.1" 456 | source = "registry+https://github.com/rust-lang/crates.io-index" 457 | checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" 458 | dependencies = [ 459 | "smawk", 460 | "unicode-linebreak", 461 | "unicode-width", 462 | ] 463 | 464 | [[package]] 465 | name = "unicode-ident" 466 | version = "1.0.13" 467 | source = "registry+https://github.com/rust-lang/crates.io-index" 468 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 469 | 470 | [[package]] 471 | name = "unicode-linebreak" 472 | version = "0.1.5" 473 | source = "registry+https://github.com/rust-lang/crates.io-index" 474 | checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" 475 | 476 | [[package]] 477 | name = "unicode-width" 478 | version = "0.1.14" 479 | source = "registry+https://github.com/rust-lang/crates.io-index" 480 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 481 | 482 | [[package]] 483 | name = "vfs" 484 | version = "0.11.0" 485 | source = "registry+https://github.com/rust-lang/crates.io-index" 486 | checksum = "0e9ee7773dfb8ff183fba701ffdf4c74c50a2aecafad43b756ba7d0f5e307c85" 487 | 488 | [[package]] 489 | name = "walkdir" 490 | version = "2.5.0" 491 | source = "registry+https://github.com/rust-lang/crates.io-index" 492 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 493 | dependencies = [ 494 | "same-file", 495 | "winapi-util", 496 | ] 497 | 498 | [[package]] 499 | name = "wasi" 500 | version = "0.11.0+wasi-snapshot-preview1" 501 | source = "registry+https://github.com/rust-lang/crates.io-index" 502 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 503 | 504 | [[package]] 505 | name = "wasm-bindgen" 506 | version = "0.2.95" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" 509 | dependencies = [ 510 | "cfg-if", 511 | "once_cell", 512 | "wasm-bindgen-macro", 513 | ] 514 | 515 | [[package]] 516 | name = "wasm-bindgen-backend" 517 | version = "0.2.95" 518 | source = "registry+https://github.com/rust-lang/crates.io-index" 519 | checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" 520 | dependencies = [ 521 | "bumpalo", 522 | "log", 523 | "once_cell", 524 | "proc-macro2", 525 | "quote", 526 | "syn", 527 | "wasm-bindgen-shared", 528 | ] 529 | 530 | [[package]] 531 | name = "wasm-bindgen-futures" 532 | version = "0.4.45" 533 | source = "registry+https://github.com/rust-lang/crates.io-index" 534 | checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" 535 | dependencies = [ 536 | "cfg-if", 537 | "futures-core", 538 | "js-sys", 539 | "wasm-bindgen", 540 | "web-sys", 541 | ] 542 | 543 | [[package]] 544 | name = "wasm-bindgen-macro" 545 | version = "0.2.95" 546 | source = "registry+https://github.com/rust-lang/crates.io-index" 547 | checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" 548 | dependencies = [ 549 | "quote", 550 | "wasm-bindgen-macro-support", 551 | ] 552 | 553 | [[package]] 554 | name = "wasm-bindgen-macro-support" 555 | version = "0.2.95" 556 | source = "registry+https://github.com/rust-lang/crates.io-index" 557 | checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" 558 | dependencies = [ 559 | "proc-macro2", 560 | "quote", 561 | "syn", 562 | "wasm-bindgen-backend", 563 | "wasm-bindgen-shared", 564 | ] 565 | 566 | [[package]] 567 | name = "wasm-bindgen-shared" 568 | version = "0.2.95" 569 | source = "registry+https://github.com/rust-lang/crates.io-index" 570 | checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" 571 | 572 | [[package]] 573 | name = "web-sys" 574 | version = "0.3.72" 575 | source = "registry+https://github.com/rust-lang/crates.io-index" 576 | checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" 577 | dependencies = [ 578 | "js-sys", 579 | "wasm-bindgen", 580 | ] 581 | 582 | [[package]] 583 | name = "winapi-util" 584 | version = "0.1.9" 585 | source = "registry+https://github.com/rust-lang/crates.io-index" 586 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 587 | dependencies = [ 588 | "windows-sys", 589 | ] 590 | 591 | [[package]] 592 | name = "windows-sys" 593 | version = "0.59.0" 594 | source = "registry+https://github.com/rust-lang/crates.io-index" 595 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 596 | dependencies = [ 597 | "windows-targets", 598 | ] 599 | 600 | [[package]] 601 | name = "windows-targets" 602 | version = "0.52.6" 603 | source = "registry+https://github.com/rust-lang/crates.io-index" 604 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 605 | dependencies = [ 606 | "windows_aarch64_gnullvm", 607 | "windows_aarch64_msvc", 608 | "windows_i686_gnu", 609 | "windows_i686_gnullvm", 610 | "windows_i686_msvc", 611 | "windows_x86_64_gnu", 612 | "windows_x86_64_gnullvm", 613 | "windows_x86_64_msvc", 614 | ] 615 | 616 | [[package]] 617 | name = "windows_aarch64_gnullvm" 618 | version = "0.52.6" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 621 | 622 | [[package]] 623 | name = "windows_aarch64_msvc" 624 | version = "0.52.6" 625 | source = "registry+https://github.com/rust-lang/crates.io-index" 626 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 627 | 628 | [[package]] 629 | name = "windows_i686_gnu" 630 | version = "0.52.6" 631 | source = "registry+https://github.com/rust-lang/crates.io-index" 632 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 633 | 634 | [[package]] 635 | name = "windows_i686_gnullvm" 636 | version = "0.52.6" 637 | source = "registry+https://github.com/rust-lang/crates.io-index" 638 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 639 | 640 | [[package]] 641 | name = "windows_i686_msvc" 642 | version = "0.52.6" 643 | source = "registry+https://github.com/rust-lang/crates.io-index" 644 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 645 | 646 | [[package]] 647 | name = "windows_x86_64_gnu" 648 | version = "0.52.6" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 651 | 652 | [[package]] 653 | name = "windows_x86_64_gnullvm" 654 | version = "0.52.6" 655 | source = "registry+https://github.com/rust-lang/crates.io-index" 656 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 657 | 658 | [[package]] 659 | name = "windows_x86_64_msvc" 660 | version = "0.52.6" 661 | source = "registry+https://github.com/rust-lang/crates.io-index" 662 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 663 | 664 | [[package]] 665 | name = "zerocopy" 666 | version = "0.7.35" 667 | source = "registry+https://github.com/rust-lang/crates.io-index" 668 | checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" 669 | dependencies = [ 670 | "byteorder", 671 | "zerocopy-derive", 672 | ] 673 | 674 | [[package]] 675 | name = "zerocopy-derive" 676 | version = "0.7.35" 677 | source = "registry+https://github.com/rust-lang/crates.io-index" 678 | checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" 679 | dependencies = [ 680 | "proc-macro2", 681 | "quote", 682 | "syn", 683 | ] 684 | --------------------------------------------------------------------------------