Página de Guillaume Hoffmann

View on GitHub

% Práctico Haskell: Entrada y Salida, ByteStrings

Preparación

Tenés que hacer este práctico en tu computadora ya que se requiere una librería que no está en la interfaz web repl.it.

Vas a necesitar 2 bibliotecas que no son parte de la libreria estándar de Haskell. Se pueden con un instalador de librerías llamado cabal. Si estás usando Windows, necesitas instalar Haskell Platform para tener cabal. Si estás usando Linux, instala el paquete llamado cabal-install.

Las bibliotecas que necesitás son aeson y text. Instalalas desde tu intefaz en línea de comando (terminal) con los comandos siguientes:

cabal update
cabal install aeson text

Necesitás ahora los archivos siguientes:

Y descomprimí clues.zip.

Tenés a mano el buscador Hoogle: https://www.haskell.org/hoogle/, en particular para consultar la documentación de Data.List, Data.Map.Strict y ByteString.Lazy.

Archivos JSON

Este proyecto involucra analizar (“parsear”) y luego buscar información dentro de un archivo JSON. JSON es un formato de intercambio de datos internacionalmente estandardizado que es fácil de leer y de escribir. No hace falta que sepas mucho para este proyecto, ¡la biblioteca aeson hace todo el trabajo!

Lo que sí hay que hacer, es asegurarnos que nuesto programa Haskell pueda encontrar los archivos JSON. Así que poné los archivos en la misma capeta que tu módulo Haskell.

Si los archivos están en otra carpeta, podés probar en GHCi el comando :!pwd. Esto va a mostrar la carpeta actual donde GHCi piensa que está. Si los archivos JSON no están en la carpeta indicada, hay dos opciones. O los podés mover ahí, o podés usar :cd para que GHCi cambie de carpeta actual. (:cd es un comando de GHCi, la falta de ! es a propósito).

Como representar texto en Haskell

El problema con las String

Como ya sabemos, el tipo String de Haskell es un sinónimo para una lista de Char:

type String = [Char]

Si bien es práctico, esto genera un problema con aplicaciones reales. Por ejemplo, consideremos la lista [1,2] y su representación concreta en memoria:

En esta ilustración:

Entonces usar listas de caracteres es una manera horriblemente costosa de almacenar texto en la memoria de una computadora.

La solución de las ByteString

Por suerte existen otras representaciones de texto. En este proyecto vamos a usar una que se llama ByteString. De manera interna, una ByteString es muy parecida a un arreglo de caracteres en C.

La biblioteca ByteString usa muchos nombres de funciones iguales a los del Prelude (el módulo por defecto de Haskell) y de Data.List. Si colocás simplemente import Data.ByteString al principio de tu módulo, vas a tener muchos conflictos de nombres en tu código. Entonces vamos a usar la sintaxis import qualified ... as BS, que significa que cada uso de una función o tipo sobre ByteString (incluso los operadores) debe ser prefijado con BS. Por ejemplo, para calcular el tamaño de una ByteString, hay que escribir BS.length.

GHC provee la extensión OverloadedStrings, que se activa con el comentario siguiente al principio de un módulo:

{-# LANGUAGE OverloadedStrings #-}

Para usar OverloadedStrings en GHCi se usa el comando :set -XOverloadedStrings.

Esto funciona como los números sobrecargados, que por ejemplo, cuando escribimos algo como 42, no es de tipo Int, Integer o Float, pero de tipo Num a => a.

Con OverloadedStrings activado, cada uso de "blah" en tu código fuente es convertido en una llamada a fromString "blah", donde fromString es una función de la clase de tipos IsString (el equivalente de Num pero para cadenas de caracteres). Por supuesto, ByteString está en la clase IsString, tal como String.

Una consecuencia de OverloadedStrings es que a veces GHC no sabe qué tipo de cadena de caracteres queremos, y de repente aparecen mensajes de error extraños. En esos casos necesitamos agregar una anotación de tipo.

Finalmente, las ByteStrings son secuencias de caracteres 8-bits tradicionales. El tipo de un carácter 8-bit en Haskell es Word8, que es básicamente un entero sin signo de 8 bits. Esto es a doble hilo. Por un lado, no podemos usar constantes como 'a' cuando manejamos ByteStrings (aunque, podemos usar la sintaxis "..." como visto más arriba). Por otro lado, los Word8 son instancias de Num e Integral, entonces podemos usar nuestras funciones numéricas favoritas sobre ellos.

¡Pánico en el Banco Haskell!

¡El Banco Haskell está en alerta! ¡Alguien hackeó el sistema haciendo uso de la función head! La falla de seguridad está arreglada, pero el delincuente pudo iniciar una serie de transacciones.

Te contrataron para encontrar quién hackeó el Banco Haskell y qué transacciones tienen que ser revertidas para que todos los clientes recuperen su dinero.

Por suerte, pudiste recuperar ciertos archivos que contienen pistas. Estos archivos se encuentran en clues.zip. En los ejercicios siguientes, vas a extraer los datos de esos archivos y usar las pistas para agarrar al perpetrador.

Ejercicio 1

El delincuente guardó una lista de los identificadores de transacción que inició. Lamentablemente, esa lista es encriptada, y el delincuente escondió la clave en una adorable foto de perro, dog.jpg. Algunos de los bytes de la imagen fuero XOR-eados con un mensaje secreto. Para extraer el mensaje, necesitás hacer un XOR de los bytes de la imagen alterada con la imagen original, y quitar todos los bytes que valen 0 después de esa operación. La imagen original se encuentra en el archivo dog-original.jpg.

Ahora, tenés que implementar la función:

getSecret :: FilePath -> FilePath -> IO ByteString

Vas a tener que usar la función xor del módulo Data.Bits. Recordá que FilePath es simplemente un sinónimo de String.

(Indicación: buscá la documentación del módulo Data.Bytestring.Lazy y Data.Bits en Hoogle.)

Ejercicio 2

Ahora que tenés la clave de encripción, podés decifrar la lista de identificadores de transacciones falsas. Esta lista es contenida en victims.json.enc.

Los datos son cifrados usando un esquema similar al Cifrado de Vigenère. Para descifrarlos, simplemente hay que XOR-ear el texto cifrado con la clave. Vas a tener que repetir la clave porque es mucho más corta que el texto cifrado.

Implementá la función:

decryptWithKey :: ByteString -> FilePath -> IO ()

Esta función debe leer el archivo cifrado, y decifrarlo usando la clave, luego escribirlo en otro archivo. El parámetro ByteString es la clave y el FilePath es el camino al archivo que debe ser escrito (no tiene que existir). El archivo cifrado tiene el mismo camino pero con “.enc” agregado al final.

Para probar esta función en GHCi activá la sobrecarga de cadenas de caracteres con :set -XOverloadedStrings, luego podrás hacer:

decryptWithKey "clave arrojada en el ejercicio anterior" "victims.json"

Ejercicio 3

Ahora tenemos una lista de identificadores de transacciones falsas, pero eso no te dice quién es el delincuente ni cuánta plata robó. Por suerte, el Banco Haskell nos proveyó una lista de todas las transacciones que ocurrieron mientras el hacker estaba en el sistema.

Esta lista en formato JSON se encuentra en el archivo transactions.json (este archivo no está cifrado). El Banco Haskell también nos proveyó el módulo de parseo que usa para convertir datos entre los tipos Haskell y ByteStrings JSON. Este módulo usa la biblioteca aeson, se encuentra en Parser.hs y exporta dos funciones:

encode :: ToJSON a => a -> ByteString
decode :: FromJSON a => ByteString -> Maybe a

También define instancias FromJSON y ToJSON para el tipo de datos Transaction. Las instancias para [Transaction] son proveidas automáticamente por aeson.

Esto significa que podés usar decode para parsear la lista de transacciones en transactions.json. Los datos en victims.json son solo una lista de cadenas de carácteres. aeson sabe como parsear eso sin usar una instancia especial. Podés entonces usar una función polimórfica para parsear estos dos archivos.

Definí la función:

parseFile :: FromJSON a => FilePath -> IO (Maybe a)

Esta función debe tomar el camino a un archivo e intentar parsearlo como formato JSON, en un valor de tipo a.

Observación: para probar en GHCi, necesitás indicar qué debe ser el tipo de salida. Por ejemplo, parseFile "victims.json" va a devolver Nothing, pero parseFile "victims.json" :: IO (Maybe [TId]) te va a devolver lo que buscás.

Ejercicio 4

Ahora sos capaz de parsear tus archivos JSON, entonces podés empezar a buscar pistas. El primer paso consiste en aislar las transacciones malas.

Implementá la función:

getBadTs :: FilePath -> FilePath -> IO (Maybe [Transaction])

Esta función toma el camino a la lista de víctimas, luego el camino a los datos de transacciones, y devuelve solo las transacciones que ocurren en la lista de víctimas.

Para manejar el tipo de datos Transaction (ubicado en el módulo Parser.hs) deberás usar la sintaxis de registro (“record syntax”).

Ejercicio 5

Ahora que decifraste y parseaste todo los datos, es hora de hacer un trabajo de investigación. Para encontrar quién es el delincuente, tenés que seguir la ruta del dinero formada por esas transacciones.

Hay una forma muy simple de hacer eso. Para cada nombre, mantener un registro de cuánto dinero esa persona ganó (o perdió) como resultado de esas transacciones. Vas a necesitar alguna forma de asociar gente (String) con cantidad de dinero (Integer). Para hacerlo eficientemente, implementá la función:

getFlow :: [Transaction] -> Map String Integer

Por ejemplo:

let ts = [ Transaction { from = "Curry", to = "Peyton Jones", amount = 10, tid = "534a8de8-5a7e-4285-9801-8585734ed3dc" } ]
in getFlow ts == fromList [ ("Curry", -10), ("Peyton Jones", 10) ]

Observación: el módulo Data.Map.Strict es importado de manera “qualified”, por lo cual tenés que prefijar todo con Map. Por ejempo, el diccionario vacío es Map.empty, insertar un valor es Map.insert, Map.findWithDefault, etc.

Ejercicio 6

Con un Map conteniendo información sobre el flujo de dinero, podés fácilmente encontrar quién es el delincuente; es la persona que colectó más dinero. Definí la función:

getCriminal :: Map String Integer -> String

Esta funcion debe tomar el diccionario del flujo de dinero y devolver el nombre de la persona que recibió más dinero.

Ejercicio 7

¡Llegó la hora de juntar todo! La función main ya es definida para que puedas compilar el archivo en un ejecutable que va a automatizar todo el proceso.

Recordá que en Haskell, el tipo de main es:

main :: IO ()

En Haskell, para obtener los argumentos en línea de comando, tenemos que usar la función getArgs del módulo System.Environment. El ejecutable tomará como argumentos los caminos de la foto de perro original, la foto alterada, el archivo de transacciones y la lista de víctimas (incluso si todavía no fue creada).

Si estos argumentos no son proveidos, se toman los valores de los archivos de clues.zip.

El extrae la clave de encripción de la imagen de perro, decodifica la lista de víctimas, la escribe en un archivo nuevo, e imprime el nombre del hacker.

Como este proyecto va a ser escrito en un módulo que no se llama Main, tenés que usar un flag para decirle a GHC que debe generar un ejecutable. Compilá con:

ghc Banco.hs -main-is Banco

¡Finalmente, ejecutá ./Banco para descubrir quién hackeó el Banco Haskell!