diff --git a/src/platform/PosixError.js b/src/platform/PosixError.js index 98e3ce0..0fc136a 100644 --- a/src/platform/PosixError.js +++ b/src/platform/PosixError.js @@ -36,74 +36,108 @@ export const ErrorCodes = { ETIMEDOUT: Symbol.for('ETIMEDOUT'), }; +// Codes taken from `errno` on Linux. +export const ErrorMetadata = new Map([ + [ErrorCodes.EPERM, { code: 1, description: 'Operation not permitted' }], + [ErrorCodes.ENOENT, { code: 2, description: 'File or directory not found' }], + [ErrorCodes.EIO, { code: 5, description: 'IO error' }], + [ErrorCodes.EACCES, { code: 13, description: 'Permission denied' }], + [ErrorCodes.EEXIST, { code: 17, description: 'File already exists' }], + [ErrorCodes.ENOTDIR, { code: 20, description: 'Is not a directory' }], + [ErrorCodes.EISDIR, { code: 21, description: 'Is a directory' }], + [ErrorCodes.EINVAL, { code: 22, description: 'Argument invalid' }], + [ErrorCodes.EMFILE, { code: 24, description: 'Too many open files' }], + [ErrorCodes.EFBIG, { code: 27, description: 'File too big' }], + [ErrorCodes.ENOSPC, { code: 28, description: 'Device out of space' }], + [ErrorCodes.EPIPE, { code: 32, description: 'Pipe broken' }], + [ErrorCodes.ENOTEMPTY, { code: 39, description: 'Directory is not empty' }], + [ErrorCodes.EADDRINUSE, { code: 98, description: 'Address already in use' }], + [ErrorCodes.ECONNRESET, { code: 104, description: 'Connection reset'}], + [ErrorCodes.ETIMEDOUT, { code: 110, description: 'Connection timed out' }], + [ErrorCodes.ECONNREFUSED, { code: 111, description: 'Connection refused' }], +]); + +export const errorFromIntegerCode = (code) => { + for (const [errorCode, metadata] of ErrorMetadata) { + if (metadata.code === code) { + return errorCode; + } + } + return undefined; +}; + export class PosixError extends Error { // posixErrorCode can be either a string, or one of the ErrorCodes above. + // If message is undefined, a default message will be used. constructor(posixErrorCode, message) { - super(message); + let posixCode; if (typeof posixErrorCode === 'symbol') { if (ErrorCodes[Symbol.keyFor(posixErrorCode)] !== posixErrorCode) { throw new Error(`Unrecognized POSIX error code: '${posixErrorCode}'`); } - this.posixCode = posixErrorCode; + posixCode = posixErrorCode; } else { const code = ErrorCodes[posixErrorCode]; if (!code) throw new Error(`Unrecognized POSIX error code: '${posixErrorCode}'`); - this.posixCode = code; + posixCode = code; } + + super(message ?? ErrorMetadata.get(posixCode).description); + this.posixCode = posixCode; } // // Helpers for constructing a PosixError when you don't already have an error message. // - static AccessNotPermitted(path) { - return new PosixError(ErrorCodes.EACCES, `Access not permitted to: '${path}'`); + static AccessNotPermitted({ message, path } = {}) { + return new PosixError(ErrorCodes.EACCES, message ?? (path ? `Access not permitted to: '${path}'` : undefined)); } - static AddressInUse() { - return new PosixError(ErrorCodes.EADDRINUSE, `Address already in use`); + static AddressInUse({ message, address } = {}) { + return new PosixError(ErrorCodes.EADDRINUSE, message ?? (address ? `Address '${address}' in use` : undefined)); } - static ConnectionRefused() { - return new PosixError(ErrorCodes.ECONNREFUSED, `Connection refused`); + static ConnectionRefused({ message } = {}) { + return new PosixError(ErrorCodes.ECONNREFUSED, message); } - static ConnectionReset() { - return new PosixError(ErrorCodes.ECONNRESET, `Connection reset`); + static ConnectionReset({ message } = {}) { + return new PosixError(ErrorCodes.ECONNRESET, message); } - static PathAlreadyExists(path) { - return new PosixError(ErrorCodes.EEXIST, `Path already exists: '${path}'`); + static PathAlreadyExists({ message, path } = {}) { + return new PosixError(ErrorCodes.EEXIST, message ?? (path ? `Path already exists: '${path}'` : undefined)); } - static FileTooLarge() { - return new PosixError(ErrorCodes.EFBIG, `File too large`); + static FileTooLarge({ message } = {}) { + return new PosixError(ErrorCodes.EFBIG, message); } - static InvalidArgument(message) { + static InvalidArgument({ message } = {}) { return new PosixError(ErrorCodes.EINVAL, message); } - static IO() { - return new PosixError(ErrorCodes.EIO, `IO error`); + static IO({ message } = {}) { + return new PosixError(ErrorCodes.EIO, message); } - static IsDirectory(path) { - return new PosixError(ErrorCodes.EISDIR, `Path is directory: '${path}'`); + static IsDirectory({ message, path } = {}) { + return new PosixError(ErrorCodes.EISDIR, message ?? (path ? `Path is directory: '${path}'` : undefined)); } - static TooManyOpenFiles() { - return new PosixError(ErrorCodes.EMFILE, `Too many open files`); + static TooManyOpenFiles({ message } = {}) { + return new PosixError(ErrorCodes.EMFILE, message); } - static DoesNotExist(path) { - return new PosixError(ErrorCodes.ENOENT, `Path not found: '${path}'`); + static DoesNotExist({ message, path } = {}) { + return new PosixError(ErrorCodes.ENOENT, message ?? (path ? `Path not found: '${path}'` : undefined)); } - static NotEnoughSpace() { - return new PosixError(ErrorCodes.ENOSPC, `Not enough space available`); + static NotEnoughSpace({ message } = {}) { + return new PosixError(ErrorCodes.ENOSPC, message); } - static IsNotDirectory(path) { - return new PosixError(ErrorCodes.ENOTDIR, `Path is not a directory: '${path}'`); + static IsNotDirectory({ message, path } = {}) { + return new PosixError(ErrorCodes.ENOTDIR, message ?? (path ? `Path is not a directory: '${path}'` : undefined)); } - static DirectoryIsNotEmpty(path) { - return new PosixError(ErrorCodes.ENOTEMPTY, `Directory is not empty: '${path}'`); + static DirectoryIsNotEmpty({ message, path } = {}) { + return new PosixError(ErrorCodes.ENOTEMPTY, message ?? (path ?`Directory is not empty: '${path}'` : undefined)); } - static OperationNotPermitted() { - return new PosixError(ErrorCodes.EPERM, 'Operation not permitted'); + static OperationNotPermitted({ message } = {}) { + return new PosixError(ErrorCodes.EPERM, message); } - static BrokenPipe() { - return new PosixError(ErrorCodes.EPIPE, 'Broken pipe'); + static BrokenPipe({ message } = {}) { + return new PosixError(ErrorCodes.EPIPE, message); } - static TimedOut() { - return new PosixError(ErrorCodes.ETIMEDOUT, 'Connection timed out'); + static TimedOut({ message } = {}) { + return new PosixError(ErrorCodes.ETIMEDOUT, message); } } diff --git a/src/platform/node/filesystem.js b/src/platform/node/filesystem.js index 90ba5d6..85b9e84 100644 --- a/src/platform/node/filesystem.js +++ b/src/platform/node/filesystem.js @@ -157,7 +157,7 @@ export const CreateFilesystemProvider = () => { const stat = await fs.promises.stat(path); if ( stat.isDirectory() && ! recursive ) { - throw PosixError.IsDirectory(path); + throw PosixError.IsDirectory({ path }); } return await fs.promises.rm(path, { recursive }); @@ -166,7 +166,7 @@ export const CreateFilesystemProvider = () => { const stat = await fs.promises.stat(path); if ( !stat.isDirectory() ) { - throw PosixError.IsNotDirectory(path); + throw PosixError.IsNotDirectory({ path }); } return await fs.promises.rmdir(path); diff --git a/src/platform/puter/filesystem.js b/src/platform/puter/filesystem.js index 6cc93c0..ca60e2f 100644 --- a/src/platform/puter/filesystem.js +++ b/src/platform/puter/filesystem.js @@ -157,7 +157,7 @@ export const CreateFilesystemProvider = ({ const stat = await puterSDK.fs.stat(path); if ( stat.is_dir && ! recursive ) { - throw PosixError.IsDirectory(path); + throw PosixError.IsDirectory({ path }); } return await puterSDK.fs.delete(path, { recursive }); @@ -168,7 +168,7 @@ export const CreateFilesystemProvider = ({ const stat = await puterSDK.fs.stat(path); if ( ! stat.is_dir ) { - throw PosixError.IsNotDirectory(path); + throw PosixError.IsNotDirectory({ path }); } return await puterSDK.fs.delete(path, { recursive: false }); diff --git a/src/puter-shell/coreutils/__exports__.js b/src/puter-shell/coreutils/__exports__.js index 1ac42ec..0b0a95b 100644 --- a/src/puter-shell/coreutils/__exports__.js +++ b/src/puter-shell/coreutils/__exports__.js @@ -28,6 +28,7 @@ import module_dcall from './dcall.js' import module_dirname from './dirname.js' import module_echo from './echo.js' import module_env from './env.js' +import module_errno from './errno.js' import module_false from './false.js' import module_grep from './grep.js' import module_head from './head.js' @@ -67,6 +68,7 @@ export default { "dirname": module_dirname, "echo": module_echo, "env": module_env, + "errno": module_errno, "false": module_false, "grep": module_grep, "head": module_head, diff --git a/src/puter-shell/coreutils/errno.js b/src/puter-shell/coreutils/errno.js new file mode 100644 index 0000000..dd36d8d --- /dev/null +++ b/src/puter-shell/coreutils/errno.js @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2024 Puter Technologies Inc. + * + * This file is part of Phoenix Shell. + * + * Phoenix Shell is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { ErrorCodes, ErrorMetadata, errorFromIntegerCode } from '../../platform/PosixError.js'; +import { Exit } from './coreutil_lib/exit.js'; + +const maxErrorNameLength = Object.keys(ErrorCodes) + .reduce((longest, name) => Math.max(longest, name.length), 0); +const maxNumberLength = 3; + +async function printSingleErrno(errorCode, out) { + const metadata = ErrorMetadata.get(errorCode); + const paddedName = errorCode.description + ' '.repeat(maxErrorNameLength - errorCode.description.length); + const code = metadata.code.toString(); + const paddedCode = ' '.repeat(maxNumberLength - code.length) + code; + await out.write(`${paddedName} ${paddedCode} ${metadata.description}\n`); +} + +export default { + name: 'errno', + usage: 'errno [OPTIONS] [NAME-OR-CODE...]', + description: 'Look up and describe errno codes.', + args: { + $: 'simple-parser', + allowPositionals: true, + options: { + list: { + description: 'List all errno values', + type: 'boolean', + short: 'l' + }, + search: { + description: 'Search for errors whose descriptions contain NAME-OR-CODEs, case-insensitively', + type: 'boolean', + short: 's' + } + } + }, + execute: async ctx => { + const { err, out } = ctx.externs; + const { positionals, values } = ctx.locals; + + if (values.search) { + for (const [errorCode, metadata] of ErrorMetadata) { + const description = metadata.description.toLowerCase(); + let matches = true; + for (const nameOrCode of positionals) { + if (! description.includes(nameOrCode.toLowerCase())) { + matches = false; + break; + } + } + if (matches) { + await printSingleErrno(errorCode, out); + } + } + return; + } + + if (values.list) { + for (const errorCode of ErrorMetadata.keys()) { + await printSingleErrno(errorCode, out); + } + return; + } + + let failedToMatchSomething = false; + const fail = async (nameOrCode) => { + await err.write(`ERROR: Not understood: ${nameOrCode}\n`); + failedToMatchSomething = true; + }; + + for (const nameOrCode of positionals) { + let errorCode = ErrorCodes[nameOrCode.toUpperCase()]; + if (errorCode) { + await printSingleErrno(errorCode, out); + continue; + } + + const code = Number.parseInt(nameOrCode); + if (!isFinite(code)) { + await fail(nameOrCode); + continue; + } + errorCode = errorFromIntegerCode(code); + if (errorCode) { + await printSingleErrno(errorCode, out); + continue; + } + + await fail(nameOrCode); + } + + if (failedToMatchSomething) { + throw new Exit(1); + } + } +}; diff --git a/test/coreutils.test.js b/test/coreutils.test.js index db8c6e4..3d98f53 100644 --- a/test/coreutils.test.js +++ b/test/coreutils.test.js @@ -20,6 +20,7 @@ import { runBasenameTests } from "./coreutils/basename.js"; import { runDirnameTests } from "./coreutils/dirname.js"; import { runEchoTests } from "./coreutils/echo.js"; import { runEnvTests } from "./coreutils/env.js"; +import { runErrnoTests } from './coreutils/errno.js'; import { runFalseTests } from "./coreutils/false.js"; import { runHeadTests } from "./coreutils/head.js"; import { runPrintfTests } from './coreutils/printf.js'; @@ -34,6 +35,7 @@ describe('coreutils', function () { runDirnameTests(); runEchoTests(); runEnvTests(); + runErrnoTests(); runFalseTests(); runHeadTests(); runPrintfTests(); diff --git a/test/coreutils/errno.js b/test/coreutils/errno.js new file mode 100644 index 0000000..fbb63b5 --- /dev/null +++ b/test/coreutils/errno.js @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2024 Puter Technologies Inc. + * + * This file is part of Phoenix Shell. + * + * Phoenix Shell is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import assert from 'assert'; +import { MakeTestContext } from './harness.js' +import builtins from '../../src/puter-shell/coreutils/__exports__.js'; +import { ErrorCodes, ErrorMetadata } from '../../src/platform/PosixError.js'; + +export const runErrnoTests = () => { + describe('errno', function () { + + const testCases = [ + { + description: 'exits normally if nothing is passed in', + input: [ ], + values: {}, + expectedStdout: '', + expectedStderr: '', + expectedFail: false, + }, + { + description: 'can search by number', + input: [ ErrorMetadata.get(ErrorCodes.EFBIG).code.toString() ], + values: {}, + expectedStdout: 'EFBIG 27 File too big\n', + expectedStderr: '', + expectedFail: false, + }, + { + description: 'can search by number', + input: [ ErrorCodes.EIO.description ], + values: {}, + expectedStdout: 'EIO 5 IO error\n', + expectedStderr: '', + expectedFail: false, + }, + { + description: 'prints an error message and returns a code > 0 if an error is not found', + input: [ 'NOT-A-REAL-ERROR' ], + values: {}, + expectedStdout: '', + expectedStderr: 'ERROR: Not understood: NOT-A-REAL-ERROR\n', + expectedFail: true, + }, + { + description: 'accepts multiple arguments and prints each', + input: [ ErrorMetadata.get(ErrorCodes.ENOENT).code.toString(), 'NOT-A-REAL-ERROR', ErrorCodes.EPIPE.description ], + values: {}, + expectedStdout: + 'ENOENT 2 File or directory not found\n' + + 'EPIPE 32 Pipe broken\n', + expectedStderr: 'ERROR: Not understood: NOT-A-REAL-ERROR\n', + expectedFail: true, + }, + { + description: 'searches descriptions if --search is provided', + input: [ 'directory' ], + values: { search: true }, + expectedStdout: + 'ENOENT 2 File or directory not found\n' + + 'ENOTDIR 20 Is not a directory\n' + + 'EISDIR 21 Is a directory\n' + + 'ENOTEMPTY 39 Directory is not empty\n', + expectedStderr: '', + expectedFail: false, + }, + { + description: 'lists all errors if --list is provided, ignoring parameters', + input: [ 'directory' ], + values: { list: true }, + expectedStdout: + 'EPERM 1 Operation not permitted\n' + + 'ENOENT 2 File or directory not found\n' + + 'EIO 5 IO error\n' + + 'EACCES 13 Permission denied\n' + + 'EEXIST 17 File already exists\n' + + 'ENOTDIR 20 Is not a directory\n' + + 'EISDIR 21 Is a directory\n' + + 'EINVAL 22 Argument invalid\n' + + 'EMFILE 24 Too many open files\n' + + 'EFBIG 27 File too big\n' + + 'ENOSPC 28 Device out of space\n' + + 'EPIPE 32 Pipe broken\n' + + 'ENOTEMPTY 39 Directory is not empty\n' + + 'EADDRINUSE 98 Address already in use\n' + + 'ECONNRESET 104 Connection reset\n' + + 'ETIMEDOUT 110 Connection timed out\n' + + 'ECONNREFUSED 111 Connection refused\n', + expectedStderr: '', + expectedFail: false, + }, + { + description: '--search overrides --list', + input: [ 'directory' ], + values: { list: true, search: true }, + expectedStdout: + 'ENOENT 2 File or directory not found\n' + + 'ENOTDIR 20 Is not a directory\n' + + 'EISDIR 21 Is a directory\n' + + 'ENOTEMPTY 39 Directory is not empty\n', + expectedStderr: '', + expectedFail: false, + }, + ]; + + for (const { description, input, values, expectedStdout, expectedStderr, expectedFail } of testCases) { + it(description, async () => { + let ctx = MakeTestContext(builtins.errno, { positionals: input, values }); + let hadError = false; + try { + const result = await builtins.errno.execute(ctx); + if (!expectedFail) { + assert.equal(result, undefined, 'should exit successfully, returning nothing'); + } + } catch (e) { + hadError = true; + if (!expectedFail) { + assert.fail(e); + } + } + if (expectedFail && !hadError) { + assert.fail('should have returned an error code'); + } + assert.equal(ctx.externs.out.output, expectedStdout, 'wrong output written to stdout'); + assert.equal(ctx.externs.err.output, expectedStderr, 'wrong output written to stderr'); + }); + } + }); +} \ No newline at end of file