chromium/ui/file_manager/file_manager/foreground/js/metadata/exif_parser_unittest.ts

// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assertEquals, assertTrue} from 'chrome://webui-test/chromeos/chai_assert.js';

import {ByteOrder, ByteReader} from './byte_reader.js';
import {type ExifEntry, ExifTag} from './exif_constants.js';
import {ExifParser} from './exif_parser.js';
import type {MetadataParserLogger} from './metadata_parser.js';

class ByteWriter {
  private view_: DataView;
  private littleEndian_ = false;
  private pos_ = 0;
  private forwards_ = {} as Record<ExifTag, {pos: number, width: number}>;

  /**
   * @param arrayBuffer Underlying buffer to use.
   * @param offset Offset at which to start writing.
   * @param length Maximum length to use.
   */
  constructor(arrayBuffer: ArrayBuffer, offset: number, length?: number) {
    const calculatedLength = length || (arrayBuffer.byteLength - offset);
    this.view_ = new DataView(arrayBuffer, offset, calculatedLength);
  }

  /**
   * If key is a number, format it in hex style.
   * @param key A key.
   * @return Formatted representation.
   */
  static prettyKeyFormat(key: (string|ExifTag)): string {
    if (typeof key === 'number') {
      return '0x' + key.toString(16);
    } else {
      return key;
    }
  }

  /**
   * Set the byte ordering for future writes.
   * @param order ByteOrder to use
   *     {ByteOrder.LITTLE_ENDIAN} or {ByteOrder.BIG_ENDIAN}.
   */
  setByteOrder(order: ByteOrder) {
    this.littleEndian_ = (order === ByteOrder.LITTLE_ENDIAN);
  }

  /**
   * @return the current write position.
   */
  tell(): number {
    return this.pos_;
  }

  /**
   * Skips desired amount of bytes in output stream.
   * @param count Byte count to skip.
   */
  skip(count: number) {
    this.validateWrite(count);
    this.pos_ += count;
  }

  /**
   * Check if the buffer has enough room to read 'width' bytes. Throws an error
   * if it has not.
   * @param width Amount of bytes to check.
   */
  validateWrite(width: number) {
    if (this.pos_ + width > this.view_.byteLength) {
      throw new Error('Writing past the end of the buffer');
    }
  }

  /**
   * Writes scalar value to output stream.
   * @param value Value to write.
   * @param width Desired width of written value.
   * @param signed True if value represents signed number.
   */
  writeScalar(value: number, width: number, signed?: boolean) {
    this.validateWrite(width);

    switch (width) {
      case 1:
        if (signed) {
          this.view_.setInt8(this.pos_, value);
        } else {
          this.view_.setUint8(this.pos_, value);
        }
        break;

      case 2:
        if (signed) {
          this.view_.setInt16(this.pos_, value, this.littleEndian_);
        } else {
          this.view_.setUint16(this.pos_, value, this.littleEndian_);
        }
        break;

      case 4:
        if (signed) {
          this.view_.setInt32(this.pos_, value, this.littleEndian_);
        } else {
          this.view_.setUint32(this.pos_, value, this.littleEndian_);
        }
        break;

      default:
        throw new Error('Invalid width: ' + width);
    }

    this.pos_ += width;
  }

  /**
   * Writes string.
   * @param str String to write.
   */
  writeString(str: string) {
    this.validateWrite(str.length);
    for (let i = 0; i !== str.length; i++) {
      this.view_.setUint8(this.pos_++, str.charCodeAt(i));
    }
  }

  /**
   * Allocate the space for 'width' bytes for the value that will be set later.
   * To be followed by a 'resolve' call with the same key.
   * @param key A key to identify the value.
   * @param width Width of the value in bytes.
   */
  forward(key: ExifTag, width: number) {
    if (key in this.forwards_) {
      throw new Error('Duplicate forward key ' + key);
    }
    this.validateWrite(width);
    this.forwards_[key] = {
      pos: this.pos_,
      width: width,
    };
    this.pos_ += width;
  }

  /**
   * Set the value previously allocated with a 'forward' call.
   * @param key A key to identify the value.
   * @param value value to write in pre-allocated space.
   */
  resolve(key: ExifTag, value: number) {
    if (!(key in this.forwards_)) {
      throw new Error('Undeclared forward key ' + key.toString(16));
    }
    const forward = this.forwards_[key];
    const curPos = this.pos_;
    this.pos_ = forward.pos;
    this.writeScalar(value, forward.width);
    this.pos_ = curPos;
    delete this.forwards_[key];
  }

  /**
   * A shortcut to resolve the value to the current write position.
   * @param key A key to identify pre-allocated position.
   */
  resolveOffset(key: ExifTag) {
    this.resolve(key, this.tell());
  }

  /**
   * Check if every forward has been resolved, throw and error if not.
   */
  checkResolved() {
    for (const key in this.forwards_) {
      throw new Error(
          'Unresolved forward pointer ' + ByteWriter.prettyKeyFormat(key));
    }
  }
}

/**
 * Creates a directory with specified tag. This method only supports string
 * format tag, which is longer than 4 characters.
 * @param bytes Bytes to be written.
 * @param tag An exif entry which will be written.
 */
function writeDirectory(bytes: ArrayBufferView, tag: ExifEntry) {
  assertEquals(2, tag.format);
  assertTrue(tag.componentCount > 4);

  const byteWriter = new ByteWriter(bytes.buffer, 0);
  byteWriter.writeScalar(1, 2);  // Number of fields.

  byteWriter.writeScalar(tag.id, 2);
  byteWriter.writeScalar(tag.format, 2);
  byteWriter.writeScalar(tag.componentCount, 4);
  byteWriter.forward(tag.id, 4);

  byteWriter.writeScalar(0, 4);  // Offset to next IFD.

  byteWriter.resolveOffset(tag.id);
  const string = tag.value;
  byteWriter.writeString(string);

  byteWriter.checkResolved();
}

class ConsoleLogger implements MetadataParserLogger {
  verbose = true;

  error(...args: Array<Object|string>) {
    console.error(...args);
  }

  log(...args: Array<Object|string>) {
    console.log(...args);
  }

  vlog(...args: Array<Object|string>) {
    console.log(...args);
  }
}

/**
 * Parses exif data bytes (with logging) and returns the parsed tags.
 * @param bytes Bytes to be read.
 * @return Tags.
 */
function parseExifData(bytes: ArrayBufferView): Record<ExifTag, ExifEntry> {
  const exifParser = new ExifParser(new ConsoleLogger());

  const tags = {} as Record<ExifTag, ExifEntry>;
  const byteReader = new ByteReader(bytes.buffer);
  assertEquals(0, exifParser.readDirectory(byteReader, tags));
  return tags;
}

/**
 * Tests that parsed exif strings have a null character termination.
 */
export function testWithoutNullCharacterTermination() {
  // Create exif with a value that does not end with null character.
  const data = new Uint8Array(0x10000);
  writeDirectory(data, {
    id: ExifTag.MAKE,   // Manufacturer Id.
    format: 2,          // String format.
    componentCount: 8,  // Length of value 'Manufact'.
    value: 'Manufact',
  });

  // Parse the exif data.
  const tags = parseExifData(data);

  // The parsed value should end in a null character.
  const parsedTag = tags[ExifTag.MAKE];
  assertEquals(9, parsedTag.componentCount);
  assertEquals('Manufact\0', parsedTag.value);
}