]> git.wincent.com - wincent.git/commitdiff
feat: hand-roll stat implementation
authorGreg Hurrell <greg@hurrell.net>
Tue, 31 Mar 2020 23:12:52 +0000 (01:12 +0200)
committerGreg Hurrell <greg@hurrell.net>
Tue, 31 Mar 2020 23:12:52 +0000 (01:12 +0200)
Due to noted issues:

- Can't stat root-owned files with restrictive perms, which means we
  can't manage them at all.
- Don't want to run as root.
- Don't want to fork a Node-powered helper process as root (ie. to
  continue using `fs.stat` API).
- Don't want to have to deal with platform issues, but what the hell, we
  are going to anyway...

src/Fig/compare.ts
src/Fig/operations/template.ts
src/run.ts [moved from src/sudo.ts with 78% similarity]
src/spawn.ts
src/stat.ts [new file with mode: 0644]

index ed96b3612bca77a083ffdfb763036a1598976d6b..481d0ecd3fa40217cd0c80206f88c5740b6f8869 100644 (file)
@@ -66,6 +66,9 @@ export default async function compare({
 
   const stats = await lstat(path);
 
+  // BUG: if you specify "owner": "root", we should be able to manage files that
+  // only root can stat, but this code stats as an unprivileged user
+  // (to fix this, port to use stat.ts instead
   if (stats instanceof Error) {
     // Can't stat; bail.
     diff.error = stats;
@@ -175,6 +178,9 @@ async function lstat(path: string): Promise<Stats | Error | null> {
   } catch (error) {
     if (error.code === 'ENOENT') {
       return null;
+    } else if (error.code === 'EACCES') {
+      // "permission denied"
+      return error;
     } else {
       return error;
     }
index f98644b3ab5cd7cfec4bd6fd2b0863c7e65e6f88..d4b38c0e6f3f265115dad648e98af16bfa1c929d 100644 (file)
@@ -3,7 +3,8 @@ import * as fs from 'fs';
 import ErrorWithMetadata from '../../ErrorWithMetadata';
 import {log} from '../../console';
 import expand from '../../expand';
-import sudo from '../../sudo';
+import run from '../../run';
+import stat from '../../stat';
 import {compile, fill} from '../../template';
 import Context from '../Context';
 import compare from '../compare';
@@ -37,7 +38,7 @@ export default async function template({
     group,
     mode,
     owner,
-    path,
+    path: target,
     state: 'file',
   });
 
@@ -46,7 +47,7 @@ export default async function template({
   if (owner && owner !== Context.attributes.username) {
     log.notice(`needs sudo: ${Context.attributes.username} -> ${owner}`);
     const passphrase = await Context.sudoPassphrase;
-    const result = await sudo('ls', ['-l', '/var/audit'], {passphrase});
+    const result = await run('ls', ['-l', '/var/audit'], {passphrase});
 
     if (result.status !== 0) {
       throw new ErrorWithMetadata(`Failed command`, {
similarity index 78%
rename from src/sudo.ts
rename to src/run.ts
index fc5a29f3e5b7a8fbad7b21bf026e470b7e8e9d6b..06816a0034ed9237c6588c489a88b96638450649 100644 (file)
@@ -2,7 +2,7 @@ import * as child_process from 'child_process';
 import {randomBytes} from 'crypto';
 
 type Options = {
-  passphrase: string;
+  passphrase?: string;
 };
 
 type Result = {
@@ -14,18 +14,24 @@ type Result = {
   stdout: string;
 };
 
-export default async function sudo(
+/**
+ * Run a command and return the result, escalating with `sudo` if a `passhprase`
+ * is supplied via the `options` parameter.
+ */
+export default async function run(
   command: string,
   args: Array<string>,
-  options: Options
+  options: Options = {}
 ): Promise<Result> {
   return new Promise((resolve, reject) => {
     const prompt = `sudo[${randomBytes(16).toString('hex')}]:`;
 
-    const sudoArgs = ['-S', '-k', '-p', prompt, '--'];
+    const final = options.passphrase
+      ? ['sudo', '-S', '-k', '-p', prompt, '--', command, ...args]
+      : [command, ...args];
 
     const result = {
-      command: ['sudo', ...sudoArgs, command, ...args].join(' '),
+      command: final.join(' '),
       error: null,
       signal: null,
       status: null,
@@ -33,7 +39,7 @@ export default async function sudo(
       stdout: '',
     };
 
-    const child = child_process.spawn('sudo', [...sudoArgs, command, ...args]);
+    const child = child_process.spawn(final[0], final.slice(1));
 
     // Sadly, we may see "Sorry, try again" if the wrong password is
     // supplied, because sudo may be configured to log it directly to
index 4d05b5586f983df18cc2e9d6cff434b9d822ff92..c1b7ed2c7ac3006af1b8d81bc31ce185dcd29cdb 100644 (file)
@@ -2,6 +2,11 @@ import * as child_process from 'child_process';
 
 import ErrorWithMetadata from './ErrorWithMetadata';
 
+/**
+ * Fire-and-forget child process execution.
+ *
+ * Doesn't return stderr or stdout; resolves on success and rejects on failure.
+ */
 export default async function spawn(
   command: string,
   ...args: Array<string>
diff --git a/src/stat.ts b/src/stat.ts
new file mode 100644 (file)
index 0000000..7b3569a
--- /dev/null
@@ -0,0 +1,102 @@
+import ErrorWithMetadata from './ErrorWithMetadata';
+import Context from './Fig/Context';
+import run from './run';
+
+type Stats = {
+  group: string;
+  mode: Mode;
+  target?: string;
+  type: 'directory' | 'file' | 'link' | 'socket' | 'special' | 'unknown';
+  user: string;
+};
+
+const TYPE_MAP = {
+  'character device': 'special',
+  'character special file': 'special',
+  directory: 'directory',
+  'regular file': 'file',
+  socket: 'socket',
+  'symbolic link': 'link',
+} as const;
+
+/**
+ * Wrapper for "stat" command.
+ *
+ * Ideally, we'd just use `fs.stat`, but the trouble with that is we can't rely
+ * on it to stat root-owned files; we need to be able to run a separate too,
+ * with `sudo` if necessary.
+ */
+export default async function stat(path: string): Promise<Stats> {
+  if (Context.attributes.platform === 'darwin') {
+    const formats = {
+      group: '%Sg',
+      mode: '%Lp',
+      newline: '%n',
+      target: '%Y',
+      type: '%HT',
+      user: '%Su',
+    };
+
+    const formatString = [
+      formats.mode,
+      formats.type,
+      formats.user,
+      formats.group,
+      formats.target,
+    ].join(formats.newline);
+
+    // Try without sudo, then with.
+    for (const sudo of [false, true]) {
+      const options = sudo
+        ? {passphrase: await Context.sudoPassphrase}
+        : undefined;
+
+      const {status, stderr, stdout} = await run(
+        'stat',
+        ['-f', formatString, path],
+        options
+      );
+
+      if (status === 0) {
+        const [mode, type, user, group, target] = stdout.split('\n');
+
+        const paddedMode = mode.padStart(4, '0');
+
+        assertMode(paddedMode);
+
+        return {
+          group,
+          mode: paddedMode,
+          target: target || undefined,
+          type: (TYPE_MAP as any)[type.toLowerCase()] || 'unknown',
+          user,
+        };
+      }
+
+      if (!/permission denied/i.test(stderr)) {
+        // Give up...
+        break;
+      }
+    }
+  } else {
+    throw new Error('TODO: implement');
+    // GNU: command stat --printf '%a\n%F\n%G\n%U\n'
+    // a = mode
+    // F = type
+    // G = group name
+    // U = user name
+    // maybe %N (link target): prints 'src' -> 'target'
+    // 644, 1777
+    // regular file, directory, symbolic link, character special file
+  }
+
+  throw new ErrorWithMetadata(`Unable to stat ${path}`);
+}
+
+const MODE_REGEXP = /^0[0-7]{3}$/;
+
+function assertMode(mode: string): asserts mode is Mode {
+  if (!MODE_REGEXP.test(mode)) {
+    throw new Error(`Invalid mode ${mode}`);
+  }
+}