<?php

final class PhabricatorFilesManagementIntegrityWorkflow
  extends PhabricatorFilesManagementWorkflow {

  protected function didConstruct() {
    $this
      ->setName('integrity')
      ->setSynopsis(pht('Verify or recalculate file integrity hashes.'))
      ->setArguments(
        array(
          array(
            'name'      => 'all',
            'help'      => pht('Affect all files.'),
          ),
          array(
            'name' => 'strip',
            'help' => pht(
              'DANGEROUS. Strip integrity hashes from files. This makes '.
              'files vulnerable to corruption or tampering.'),
          ),
          array(
            'name' => 'corrupt',
            'help' => pht(
              'Corrupt integrity hashes for given files. This is intended '.
              'for debugging.'),
          ),
          array(
            'name' => 'compute',
            'help' => pht(
              'Compute and update integrity hashes for files which do not '.
              'yet have them.'),
          ),
          array(
            'name' => 'overwrite',
            'help' => pht(
              'DANGEROUS. Recompute and update integrity hashes, overwriting '.
              'invalid hashes. This may mark corrupt or dangerous files as '.
              'valid.'),
          ),
          array(
            'name' => 'force',
            'short' => 'f',
            'help' => pht(
              'Execute dangerous operations without prompting for '.
              'confirmation.'),
          ),
          array(
            'name'      => 'names',
            'wildcard'  => true,
          ),
        ));
  }

  public function execute(PhutilArgumentParser $args) {
    $modes = array();

    $is_strip = $args->getArg('strip');
    if ($is_strip) {
      $modes[] = 'strip';
    }

    $is_corrupt = $args->getArg('corrupt');
    if ($is_corrupt) {
      $modes[] = 'corrupt';
    }

    $is_compute = $args->getArg('compute');
    if ($is_compute) {
      $modes[] = 'compute';
    }

    $is_overwrite = $args->getArg('overwrite');
    if ($is_overwrite) {
      $modes[] = 'overwrite';
    }

    $is_verify = !$modes;
    if ($is_verify) {
      $modes[] = 'verify';
    }

    if (count($modes) > 1) {
      throw new PhutilArgumentUsageException(
        pht(
          'You have selected multiple operation modes (%s). Choose a '.
          'single mode to operate in.',
          implode(', ', $modes)));
    }

    $is_force = $args->getArg('force');
    if (!$is_force) {
      $prompt = null;
      if ($is_strip) {
        $prompt = pht(
          'Stripping integrity hashes is dangerous and makes files '.
          'vulnerable to corruption or tampering.');
      }

      if ($is_corrupt) {
        $prompt = pht(
          'Corrupting integrity hashes will prevent files from being '.
          'accessed. This mode is intended only for development and '.
          'debugging.');
      }

      if ($is_overwrite) {
        $prompt = pht(
          'Overwriting integrity hashes is dangerous and may mark files '.
          'which have been corrupted or tampered with as safe.');
      }

      if ($prompt) {
        $this->logWarn(pht('DANGEROUS'), $prompt);

        if (!phutil_console_confirm(pht('Continue anyway?'))) {
          throw new PhutilArgumentUsageException(pht('Aborted workflow.'));
        }
      }
    }

    $iterator = $this->buildIterator($args);
    if (!$iterator) {
      throw new PhutilArgumentUsageException(
        pht(
          'Either specify a list of files to affect, or use "--all" to '.
          'affect all files.'));
    }

    $failure_count = 0;
    $total_count = 0;

    foreach ($iterator as $file) {
      $total_count++;
      $display_name = $file->getMonogram();

      $old_hash = $file->getIntegrityHash();

      if ($is_strip) {
        if ($old_hash === null) {
          $this->logInfo(
            pht('SKIPPED'),
            pht(
              'File "%s" does not have an integrity hash to strip.',
              $display_name));
        } else {
          $file
            ->setIntegrityHash(null)
            ->save();

          $this->logWarn(
            pht('STRIPPED'),
            pht(
              'Stripped integrity hash for "%s".',
              $display_name));
        }

        continue;
      }

      $need_hash = ($is_verify && $old_hash) ||
                   ($is_compute && ($old_hash === null)) ||
                   ($is_corrupt) ||
                   ($is_overwrite);
      if ($need_hash) {
        try {
          $new_hash = $file->newIntegrityHash();
        } catch (Exception $ex) {
          $failure_count++;

          $this->logFail(
            pht('ERROR'),
            pht(
              'Unable to compute integrity hash for file "%s": %s',
              $display_name,
              $ex->getMessage()));

          continue;
        }
      } else {
        $new_hash = null;
      }

      // NOTE: When running in "corrupt" mode, we only corrupt the hash if
      // we're able to compute a valid hash. Some files, like chunked files,
      // do not support integrity hashing so corrupting them would create an
      // unusual state.

      if ($is_corrupt) {
        if ($new_hash === null) {
          $this->logInfo(
            pht('IGNORED'),
            pht(
              'Storage for file "%s" does not support integrity hashing.',
              $display_name));
        } else {
          $file
            ->setIntegrityHash('<corrupted>')
            ->save();

          $this->logWarn(
            pht('CORRUPTED'),
            pht(
              'Corrupted integrity hash for file "%s".',
              $display_name));
        }

        continue;
      }

      if ($is_verify) {
        if ($old_hash === null) {
          $this->logInfo(
            pht('NONE'),
            pht(
              'File "%s" has no stored integrity hash.',
              $display_name));
        } else if ($new_hash === null) {
          $failure_count++;

          $this->logWarn(
            pht('UNEXPECTED'),
            pht(
              'Storage for file "%s" does not support integrity hashing, '.
              'but the file has an integrity hash.',
              $display_name));
        } else if (phutil_hashes_are_identical($old_hash, $new_hash)) {
          $this->logOkay(
            pht('VALID'),
            pht(
              'File "%s" has a valid integrity hash.',
              $display_name));
        } else {
          $failure_count++;

          $this->logFail(
            pht('MISMATCH'),
            pht(
              'File "%s" has an invalid integrity hash!',
              $display_name));
        }

        continue;
      }

      if ($is_compute) {
        if ($old_hash !== null) {
          $this->logInfo(
            pht('SKIP'),
            pht(
              'File "%s" already has an integrity hash.',
              $display_name));
        } else if ($new_hash === null) {
          $this->logInfo(
            pht('IGNORED'),
            pht(
              'Storage for file "%s" does not support integrity hashing.',
              $display_name));
        } else {
          $file
            ->setIntegrityHash($new_hash)
            ->save();

          $this->logOkay(
            pht('COMPUTE'),
            pht(
              'Computed and stored integrity hash for file "%s".',
              $display_name));
        }

        continue;
      }

      if ($is_overwrite) {
        $same_hash = ($old_hash !== null) &&
                     ($new_hash !== null) &&
                     phutil_hashes_are_identical($old_hash, $new_hash);

        if ($new_hash === null) {
          $this->logInfo(
            pht('IGNORED'),
            pht(
              'Storage for file "%s" does not support integrity hashing.',
              $display_name));
        } else if ($same_hash) {
          $this->logInfo(
            pht('UNCHANGED'),
            pht(
              'File "%s" already has the correct integrity hash.',
              $display_name));
        } else {
          $file
            ->setIntegrityHash($new_hash)
            ->save();

          $this->logOkay(
            pht('OVERWRITE'),
            pht(
              'Overwrote integrity hash for file "%s".',
              $display_name));
        }

        continue;
      }
    }

    if ($failure_count) {
      $this->logFail(
        pht('FAIL'),
        pht(
          'Processed %s file(s), encountered %s error(s).',
          new PhutilNumber($total_count),
          new PhutilNumber($failure_count)));
    } else {
      $this->logOkay(
        pht('DONE'),
        pht(
          'Processed %s file(s) with no errors.',
          new PhutilNumber($total_count)));
    }

    return 0;
  }

}
