Introduction

Ce site contient les travaux pratiques du cours SE302b proposé par Guillaume Duc et Samuel Tardieu dans le cadre de l'option Systèmes Embarqués de Télécom Paris. Son utilisation est réservée aux étudiants de l'Institut Polytechnique de Paris.

Une archive de l'énoncé des travaux pratiques est disponible pour le travail hors-ligne : book.tar.xz.

ⓒ 2021-2023 Guillaume Duc et Samuel Tardieu – tous droits réservés

Installation des outils

Les travaux pratiques de cette formation sont prévus pour être exécutés sous Linux ou OSX. L'utilisation de Windows n'a pas été testée mais est probablement possible.

Les apprenants doivent disposer d'un ordinateur sur lequel ils peuvent installer des logiciels. La plupart de ces logiciels seront installés dans le répertoire personnel de l'utilisateur, mais certains pourront nécessiter une installation au niveau du système d'exploitation ou des manipulations supplémentaires si cela n'est pas possible.

Chaîne de compilation Rust

Les outils nécessaires pour la compilation en Rust peuvent être installés avec rustup, qui peut venir avec votre distribution ou être installé depuis ce site web. rustup se chargera ensuite de maintenir le compilateur et les utilitaires à jour.

Dans cette formation, nous utiliserons la chaîne de compilation stable :

$ rustup default stable

On pourra vérifier quelle(s) chaîne(s) de compilation sont installées et laquelle est sélectionnée par défaut :

$ rustup toolchain list
stable-x86_64-unknown-linux-gnu (default)

Vérification de l'installation

L'installation du compilateur lui-même peut être vérifiée en allant dans /tmp et en créant un fichier t.rs contenant :

fn main() {
  println!("Hello, world!");
}

et en exécutant dans ce même répertoire les commandes suivantes :

$ rustc t.rs
$ ./t
Hello, world!

En pratique, nous n'appellerons jamais rustc directement, nous passerons par cargo.

Composants additionnels

Nous utiliserons également les composants cargo, rustfmt et clippy. Ceux-ci ont probablement été installés automatiquement par rustup, mais la commande suivante permettra de s'en assurer :

$ rustup component add rustfmt clippy

On pourra vérifier quels composants sont installés :

$ rustup component list --installed
cargo-x86_64-unknown-linux-gnu
clippy-x86_64-unknown-linux-gnu
rust-docs-x86_64-unknown-linux-gnu
rust-std-x86_64-unknown-linux-gnu
rustc-x86_64-unknown-linux-gnu
rustfmt-x86_64-unknown-linux-gnu

Cibles de compilation

Rust permet de compiler nativement ou en configuration croisée, par exemple pour programmer des systèmes embarqués. Par défaut, seule la cible correspondant à l'ordinateur courant est disponible (commande exécutée sur un système Linux) :

$ rustup target list --installed
x86_64-unknown-linux-gnu

En omettant --installed il est possible de voir quelles cibles additionnelles peuvent être installées.

$ rustup target list
aarch64-apple-darwin
aarch64-apple-ios
aarch64-apple-ios-sim
aarch64-fuchsia
aarch64-linux-android
aarch64-pc-windows-msvc
[…]
x86_64-sun-solaris
x86_64-unknown-freebsd
x86_64-unknown-illumos
x86_64-unknown-linux-gnu (installed)
x86_64-unknown-linux-gnux32
x86_64-unknown-linux-musl
x86_64-unknown-netbsd
x86_64-unknown-redox

Ce cours n'utilisant que des cibles natives il n'est pas nécessaire d'installer d'autres cibles.

Outils Rust additionnels

Nous utiliserons certains outils disponibles depuis crates.io que nous pouvons utiliser grâce à cargo :

  • cargo-edit : fournit les sous-commandes cargo add et cargo rm pour ajouter ou retirer des dépendances d'un projet Rust
  • cargo-criterion : fournit la sous-commande cargo criterion utilisée lors des benchmarks
  • cargo-fuzz : fournit la sous-commande cargo fuzz utilisée lors des recherches d'erreurs par fuzzing

La commande suivante ajoute ces outils à l'environnement s'ils ne sont pas déjà présents :

$ cargo install cargo-edit cargo-criterion cargo-fuzz

Note

L'installation de cargo-edit présuppose la présence sur l'ordinateur des outils de développement de base, des bibliothèques de développement OpenSSL et de pkg-config. Sur une distribution Debian ou Ubuntu, on pourra les installer avec :

$ sudo apt-get install build-essential libssl-dev pkg-config

Environnement de développement

Il est possible d'utiliser n'importe quel éditeur de texte pour éditer les programmes Rust. Cependant, nous conseillons l'utilisation de Visual Studio Code et de son extension rust-analyzer. L'extension peut être ajoutée depuis Visual Studio Code depuis le panneau de gestion des extensions ou en utilisant la fonctionnalité quick open (ctrl-P) et en tapant ext install rust-lang.rust-analyzer.

Lors de l'ouverture d'un projet Rust, l'extension chargera une version précompilée du Rust Language Server (RLS), un programme qui partage du code en commun avec le compilateur pour analyser le code source Rust et renvoyer des informations à Visual Studio Code en utilisant le protocole Language Server Protocol (LSP). C'est ainsi que les fonctionnalités de navigation, complétion et refactorisation sont disponibles à travers Visual Studio Code, ainsi que l'accès direct à la documentation.

Utilisation avec un autre éditeur

D'autres éditeurs supportent, moyennant une configuration manuelle, l'intégration avec rust-analyzer, mais les fonctionnalités sont en général limitées.

Option, Result, traits

Dans cette partie, nous allons construire un premier programme que nous serons amenés à étendre par la suite. Ce programme prendra en entrée un certain nombre de comptes (des couples login password) et fera dessus un certain nombre de vérifications : mots de passe dupliqués, mots de passe déjà retrouvés dans des failles de sécurité, etc.

  1. Nous allons commencer par demander à cargo la création d'un nouveau projet pwdchk de type bin. Créez le avec la commande suivante :
$ cargo new --bin pwdchk

Le --bin n'est pas obligatoire car c'est le mode par défaut, par opposition à --lib qui crée une bibliothèque. La hiérarchie ainsi créée contient :

pwdchk/
├─Cargo.toml
└─src/
  └─main.rs

Le contenu du fichier Cargo.toml indique le nom du crate, sa version et l'édition du langage à utiliser :

[package]
name = "pwdchk"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

src/main.rs contient le programme principal :

fn main() {
    println!("Hello, world!");
}

Après être entré dans le répertoire pwdchk, on peut construire l'exécutable et le lancer :

$ cargo build
$ target/debug/pwdchk
Hello, world!

ou utiliser cargo run pour le construire et le lancer en même temps :

$ cargo run
Hello, world!

L'option --release de cargo build et cargo run permet d'utiliser le profil release plutôt que le profil debug. Le code généré est optimisé, certaines vérifications ne se font plus et le temps de compilation est allongé :

$ cargo build --release
$ target/release/pwdchk
Hello, world!
$ cargo run --release
Hello, world!

Structure de compte utilisateur

On souhaite écrire un programme qui vérifie certaines propriétés sur un ensemble de comptes utilisateur. On dispose des identifiants (login) et des mots de passe (password) de ces comptes.

Nous allons commencer par créer la structure permettant de décrire un compte utilisateur :

  1. Créez un nouveau module account dans le projet pwdchk. Le corps du module devra se trouver soit dans src/account.rs, soit dans src/account/mod.rs.
  2. Référencez le module account depuis src/main.rs pour qu'il soit compilé lors d'un cargo build.
  3. Créez une structure Account contenant deux champs de type String : login et password.
  4. Ajoutez dans l'implémentation de Account une méthode de classe new() qui reçoit le login et le password en paramètres de type &str et renvoie un nouvel Account reprenant ces informations.
struct Account { /* Ajouter les champs ici */ }
impl Account {
  fn new(login: &str, password: &str) -> Self {
    todo!()   // À implémenter
  }
}
  1. Créez une variable de type Account depuis main() en utilisant Account::new(). À quel endroit faut-il augmenter la visibilité en ajoutant pub pour pouvoir créer une variable de type Account et appeler la méthode new() ?
  2. Depuis main(), affichez le compte nouvellement créé :
fn main() {
  let account = Account::new("johndoe", "super:complex:password");
  println!("{account:?}");
}

qui devra afficher

Account { login: "johndoe", password: "super:complex:password" }

On notera qu'on demande à utiliser le trait Debug de Account grâce à l'utilisation de {:?}. Ce trait peut être dérivé automatiquement en précédant la définition de la structure par #derive(Debug) :

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Account {
  /* Ajouter les champs ici */
}
}

On aurait pu utiliser {} mais cela aurait nécessité d'implémenter le trait Display pour Account. Contrairement à Debug, Display ne peut pas être dérivé automatiquement.

Différentes écritures possibles

Les macros de type format! (dont println!, write!, panic!, etc.) possèdent plusieurs méthodes pour désigner leurs arguments. Toutes les formes ci-dessous sont équivalentes :

fn main() {
  let account = Account::new("johndoe", "super:complex:password");
  println!("{:?}", account);       // Each {} uses a new argument
  println!("{0:?}", account);      // Use the first argument (0)
  println!("{a:?}", a = account);  // Use the `a` named argument
  println!("{account:?}");         // Take argument from named arguments or context
}

La dernière forme ne permet pas de mettre des expressions complexes entre accolades, seules des variables peuvent être utilisées.

Forme alternative et arguments

Il est également possible d'opter pour une forme mieux formattée mais moins compacte d'affichage de la structure en utilisant {:#?}, le modificateur # signifiant ici l'utilisation de la forme alternative :

fn main() {
  let account = Account::new("johndoe", "super:complex:password");
  println!("account = {account:#?}");
}

On obtiendra alors la sortie :

Account {
    login: "johndoe",
    password: "super:complex:password",
}

Cette forme alternative est disponible pour un certain nombre de formats. Par exemple, le programme suivant

fn main() {
  println!("forme normale hexa = {0:x}, forme alternative = {0:#x}", 42);
}

affichera

forme normale hexa = 2a, forme alternative = 0x2a

On notera que l'utilisation d'un argument positionnel (0) évite de répéter l'argument.

Initialisation depuis une chaîne

On souhaite maintenant créer un compte utilisateur de type Account à partir d'une chaîne de caractère de la forme "login:password". Les prérequis sont :

  • Le login ne contiendra ni caractère : ni retour à la ligne.
  • Le password ne contiendra pas de retour à la ligne.
  1. Écrire une méthode de classe from_string qui prend une chaîne "login:password" en paramètre et qui renvoie un Account :
impl Account {
  pub fn from_string(s: &str) -> Self {
    todo!()
  }
}

Cette méthode supposera que la chaîne donnée respecte les prérequis indiqués ci-dessus. On pourra chercher dans la documentation Rust une méthode appropriée parmi celles disponibles sur le type str.

  1. Dans main(), créez un nouveau compte utilisateur et vérifiez que ce compte contient bien les informations attendues :
fn main() {
  println!("{:?}", Account::from_string("johndoe:super:complex:password"));
}

affichera

Account { login: "johndoe", password: "super:complex:password" }

Gestion des erreurs

La méthode Account::from_string() ne gère pas les erreurs et cela se reflète par le fait qu'elle renvoie systématiquement un Account. Le code suivant provoquera probablement un panic!() :

fn main() {
  println!("{:?}", Account::from_string("johndoe"));
}

Afin de pouvoir gérer les erreurs, nous devons choisir quelle erreur remonter à l'appelant lorsqu'aucun : n'est trouvé. Nous allons définir un type vide NoColon dans le module account qui nous servira pour signaler une erreur :

pub struct NoColon;

Cela nous permettra de renvoyer éventuellement un type Result<Account, NoColon> lorsque cela sera approprié.

  1. Créez ce type NoColon dans le module account.

FromStr

Plutôt que modifier la méthode Account::from_string(), nous allons utiliser le trait prédéfini FromStr pour convertir un &str en Account en implémentant FromStr pour Account.

  1. Implémentez FromStr pour Account et renvoyez une erreur NoColon si aucun : n'est trouvé.

Depuis main(), il devrait être possible de faire :

fn main() {
  match Account::from_str("johndoe") {
    Ok(account) => println!("{account:?}"),
    Err(e) => println!("Erreur {e:?}"),
  }
}

Vous noterez que l'affichage de l'erreur n'est pas possible ; en effet, le type NoColon n'implémente pas Debug.

  1. Faîtes dériver Debug automatiquement pour le type NoColon et vérifiez que le programme fonctionne comme attendu.

  2. Vous pouvez supprimer la méthode Account::from_string() que nous n'utiliserons plus.

  3. Changez le type de retour de main() pour qu'il soit maintenant Result<(), NoColon>. Utilisez ? pour retourner prématurément en cas d'erreur. Cela se passera bien car NoColon implémente Debug, ce qui est obligatoire si on veut l'utiliser dans le type de retour de main() pour pouvoir afficher l'erreur qui a provoqué la fin de main().

fn main() -> Result<(), NoColon> {
  println!("{:?}", Account::from_str("johndoe")?);
  Ok(())
}

Gestion de la ligne de commande

  1. En utilisant l'itérateur std::env::args() et la méthode collect(), construisez dans main() un vecteur (Vec) de Account à partir des arguments donnés sur la ligne de commande et affichez ce vecteur (si les éléments implémentent Debug, le vecteur implémente également Debug). On n'oubliera pas qu'on peut passer d'une variable String à sa représentation &str en utilisant la méthode as_str().

On se souviendra qu'il est possible d'indiquer le type de résultat voulu à la méthode collect() de deux manières : soit le contexte est non-ambigu et permet de déterminer le type de collection à construire, soit il est possible d'indiquer un type complet ou partiel avec l'opérateur turbofish ::<> :

fn main() {
  // Utilisation d'un contexte totalement explicite
  let v: Vec<u32> = (1..=10).collect();
  println!("{v:?}");
  
  // Utilisation d'un contexte partiellement explicite
  let v: Vec<_> = (1u32..=10).collect();
  println!("{v:?}");

  // Utilisation du turbofish
  let v = (1u32..=10).collect::<Vec<_>>();
  println!("{v:?}");
}

On peut bien sûr préciser des types plus complexes, comme Vec<Result<_, _>>. Voici un exemple d'utilisation de notre programme :

$ cargo run -- johndoe:complex:password johndoe johndoe:password
[
    Ok(
        Account {
            login: "johndoe",
            password: "complex:password",
        },
    ),
    Err(
        NoColon,
    ),
    Ok(
        Account {
            login: "johndoe",
            password: "password",
        },
    ),
]

On pourra noter l'utilisation de -- sur la ligne de commande de cargo run pour séparer les arguments passés à cargo run de ceux passés à l'application. Ce n'est pas obligatoire ici mais le devient dès lors qu'on voudra passer des options commençant par - à notre application.

  1. Si vous ne l'avez pas fait précédemment, utilisez le fait que collect() appliqué à un itérateur sur des valeurs de type Result<T, E> peut produire un Result<Collection<T>, E>Collection est tout type de collection comme Vec. Ce collecteur s'arrête et renvoie une erreur dès lors qu'il en rencontre une dans l'itérateur. Dans ce cas, utilisez ? pour quitter main() avec l'erreur renvoyée, et sinon affichez les comptes utilisateur décodés.
$ cargo run -- johndoe:complex:password johndoe johndoe:password
Error: NoColon
$ cargo run -- johndoe:complex:password johndoe:password
[
    Account {
        login: "johndoe",
        password: "complex:password",
    },
    Account {
        login: "johndoe",
        password: "password",
    },
]

Identification des duplicats

Nous cherchons à identifier des comptes utilisateur partageant le même mot de passe. En effet, cela peut être un signe qui permet d'identifier deux comptes utilisateurs appartenant à la même personne physique.

Pour cela, nous allons construire une HashMap pour associer à un mot de passe les logins des comptes qui l'utilisent. Nous voulons construire une fonction group() dans le module account qui groupe les Account par mot de passe :

fn group(accounts: Vec<Account>) -> HashMap<String, Vec<String>> {
  todo!()
}
  1. Implémentez la fonction account::group(). On regardera avec intérêt la méthode HashMap::entry() et les méthodes or_insert() ou or_insert_with() applicables sur ce type Entry.
  2. Affichez le résultat de cette fonction appliquée sur les comptes définis sur la ligne de commande depuis main().
$ cargo run -- johndoe:abcde janedoe:1234 admin:abcde
{
    "abcde": [
        "johndoe",
        "admin",
    ],
    "1234": [
        "janedoe",
    ],
}
  1. En utilisant la méthode retain() de HashMap, ne conservez que les entrées correspondant à plusieurs comptes et n'affichez que ceux-ci.
$ cargo run -- johndoe:abcde janedoe:1234 admin:abcde
{
    "abcde": [
        "johndoe",
        "admin",
    ],
}
  1. Améliorez l'affichage en affichant une ligne par mot de passe partagé. Pour grouper les logins, vous pourrez utiliser la méthode Vec::join().
$ cargo run -- johndoe:abcde janedoe:1234 admin:abcde anonymous:1234
Password 1234 used by janedoe, anonymous
Password abcde used by johndoe, admin

Gestion de la ligne de commande

Jusqu'ici nous avons géré les arguments donnés sur la ligne de commande à la main grâce à l'itérateur std::env::args(). Il existe des crates permettant de gérer de manière plus complète la ligne de commande. Le plus connu d'entre eux est clap.

clap est capable de parser les arguments, sous-commandes et les options fournis sur la ligne de commande et de vérifier qu'ils correspondent à la spécification.

Nous allons configurer clap pour ajouter une sous-commande group qui affichera les logins ayant le même mot de passe comme nous faisions précédemment.

  1. Ajoutez le crate clap (version 3.x) à votre projet grâce à cargo add clap@3 --features derive. Cette sous-commande de cargo, venant du crate cargo-edit que vous avez installé précedemment, ajoute la dernière version du crate à votre projet avec les options derive de ce crate qui permet d'utiliser Parser.

  2. Après avoir importé les entités nécessaires du crate clap, créez la description de votre application :

use clap::{Args, Parser, Subcommand};

#[derive(Parser)]
#[clap(version, author, about)]
struct AppArgs {
    #[clap(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Check duplicate passwords from command line
    Group(GroupArgs),
}

#[derive(Args)]
struct GroupArgs {
    #[clap(required = true)]
    /// Account to check
    account: Vec<Account>,
}

fn main() {
  // Check command line
  let args = AppArgs::parse();
  … 
}

Décortiquons ce qu'on a décrit :

  • Nous avons une application dont le nom, la version, les auteurs et la description proviennent respectivement des champs name, version, authors et description de Cargo.toml lorsqu'ils sont définis.
  • Une sous-commande existe qui a comme nom group. Les arguments de cette sous-commande sont décrits dans la structure GroupArgs.
  • Cette sous-commande a un argument ACCOUNT dont l'aide est "Account to check", dont la présence est obligatoire, et qui peut être répété puisque nous utilisons Vec<Account>.
  • Étant donné que nous avons implémenté le trait FromStr pour Account, le parseur nous générera automatiquement un vecteur du bon type.
  • Une sous-commande doit être obligatoirement précisée, sinon le message d'aide sera produit et le programme s'arrêtera. Nous n'avons qu'une seule sous-commande, il faudra donc systématiquement préciser "group" sur la ligne de commande.

Exploration des messages d'aide

Rien qu'en insérant cela dans main(), nous pouvons vérifier les messages d'aide générés avant même de modifier notre programme pour en tenir compte :

$ cargo run
pwdchk 0.1.0

USAGE:
    pwdchk <SUBCOMMAND>

OPTIONS:
    -h, --help       Print help information
    -V, --version    Print version information

SUBCOMMANDS:
    group     Check duplicate passwords from command line
    help      Print this message or the help of the given subcommand(s)

Une sous-commande help a été insérée automatiquement. Utilisons la pour voir la documentation de notre sous-commande group :

$ cargo run -- help group
pwdchk-group
Check duplicate passwords from command line

USAGE:
    pwdchk group [OPTIONS] <ACCOUNT>...

ARGS:
    <ACCOUNT>...    Account to check

OPTIONS:
    -h, --help      Print help information

On remarquera que ces messages d'aide ont été générés à partir de nos commentaires sur les arguments. Comme on l'a expliqué précédemment, le séparateur -- permet à cargo run de comprendre que les arguments sont destinés à l'application lancée et pas à cargo run lui-même. Ici ce n'est pas obligatoire, mais si on avait passé des arguments comme -h cela aurait été nécessaire :

$ cargo run -h
cargo-run
Run a binary or example of the local package

USAGE:
    cargo run [OPTIONS] [--] [args]...

OPTIONS:
    -q, --quiet                      Do not print cargo log messages
        --bin <NAME>...              Name of the bin target to run
        --example <NAME>...          Name of the example target to run
[…]

$ cargo run -- -h
pwdchk 0.1.0

USAGE:
    pwdchk <SUBCOMMAND>

OPTIONS:
    -h, --help       Print help information
    -V, --version    Print version information

SUBCOMMANDS:
    group     Check duplicate passwords from command line
    help      Print this message or the help of the given subcommand(s)

Adaptation de notre code

En cas d'utilisation correcte de la ligne de commande, la variable args contient une structure AppArgs. En cas d'erreur, le programme termine avec un message d'aide.

Nous pouvons extraire la sous-commande utilisée ainsi que ses arguments.

fn main() {
    let args = AppArgs::parse();
    match args.command {
        Command::Group(args) => {   // args is of type GroupArgs here
           // Grouper les comptes collectés dans args.account, les filtrer, afficher
           // ceux ayant plus d'un login en réutilisant le code écrit précédemment.
           todo!()
        }
    }
  }
  1. En utilisant le modèle ci-dessus comme exemple, modifiez votre application pour qu'elle fasse ce qui est demandé.

Maintenant qu'on dispose d'un traitement de la ligne de commande adéquat, il sera facile d'ajouter de nouvelles fonctionnalités tout en conservant les précédentes. N'hésitez pas à parcourir la documentation de clap pour en découvrir toutes les possibilités.

Chargement depuis un fichier

Nous souhaitons pouvoir charger nos listes de comptes depuis un fichier plutôt que les passer sur la ligne de commande. Cela permettra de traiter un grand nombre de comptes sans être limité par la taille maximale de la ligne de commande.

Au préalable, nous devons réaliser que nous allons avoir deux types d'erreur à propager :

  • les erreurs liées aux entrées-sorties, de type std::io::Error ;
  • les erreurs liées à l'analyse de la ligne "login:password", de type account::NoColon.

Étant donné que les structures Result<T, E> ne contiennent qu'un seul type d'erreur, de type E, nous avons deux choix d'implémentation possibles :

  • définir un nouveau type d'erreur qui nous est propre et qui peut encapsuler l'une ou l'autre de nos erreurs ;
  • implémenter le trait std::error::Error sur account::NoColon (qui est déjà implémenté pour std::io::Error) et utiliser le type dynamique Box<dyn std::error::Error> pour représenter une erreur générique.

Nous allons choisir la première méthode et définir un enum Error dans un module error de notre programme qui encapsulera les deux types d'erreurs possibles.

Création d'un type error::Error

  1. Créez un nouveau module error dans le projet.
  2. Définissez une énumération error::Error permettant d'encapsuler les deux formes d'erreur
#[derive(Debug)]
pub enum Error {
  IoError(std::io::Error),
  NoColon,
}
  1. Supprimez la définition du type vide account::NoColon, et remplacez son utilisation par error::Error dans les signatures de fonctions et par error::Error::NoColon pour signaler l'occurrence de l'erreur.

  2. Implémentez le trait From<std::io::Error> pour error::Error. Cela permettra à l'opérateur ? de convertir grâce à l'appel implicite à into() une erreur de type std::io::Error en error::Error.

Si vous le souhaitez, vous pouvez utiliser le crate thiserror pour implémenter plus facilement vos erreurs et des conversions automatiques. Cela nécessite d'en lire la documentation.

Chargement depuis un fichier

  1. Écrivez une méthode Account::from_file() permettant de charger un compte par ligne depuis un fichier, avec la signature suivante :
impl Account {
  …

  fn from_file(filename: &Path) -> Result<Vec<Account>, error::Error> {
    todo!()
  }
}

On pourra utilement regarder les fonctions Path::new(), File::open(), BufReader::new() qui permet d'encapsuler un descripteur de fichier dans un lecteur intelligent, le trait BufRead et notamment sa méthode lines() qui renvoie un itérateur sur les lignes d'un lecteur intelligent, ainsi que la méthode Result::map_err() qui permet de transformer une erreur en une autre.

On rappelera également que pour convertir une entité t de type T en type U, on pourra utiliser :

  • t.into() si le contexte indique qu'un type U est requis et que T implémente Into<U> ou que U implémente From<T>
  • U::from(t) si U implémente From<T>

Dit autrement, il sera possible au besoin de convertir une entité r contenant un Result<T, std::io::Error> en Result<T, error:Error> en appelant x.map_err(|e| error::Error::from(e)) ou de manière plus concise x.map_err(error::Error::from).

  1. Ajoutez une option --file=FILE (avec la version courte -f FILE) à la sous-commande group de votre programme pour charger la liste des comptes depuis le fichier FILE. Ce fichier sera optionnel:
struct AppArgs {
    …
    #[clap(short, long)]
    /// Load passwords from a file
    file: Option<PathBuf>,
}

Pour que notre programme fonctionne sur des systèmes de fichiers dont les noms ne sont pas codés en UTF-8 il faut prendre soin de ne pas mettre la valeur de l'argument dans une String mais dans un PathBuf. Ce type manipule des noms et chemins de fichiers qui ne sont pas nécessairement des chaînes UTF-8 valides.

  1. Indiquez que les comptes données sur la ligne de commande ne sont plus obligatoires en modifiant l'attribut #[clap] sur ce champ.

  2. Indiquez qu'un des deux paramètres file ou account sont obligatoires en ajoutant l'attribut idoine sur la structure AppArgs:

#[clap(group(
    ArgGroup::new("input")
        .required(true)
        .args(&["account", "file"]),
))]
struct AppArgs {
  …
}
  1. Vérifiez que votre programme se comporte bien quand vous précisez un nom de fichier, qu'il signale bien une erreur quand un mauvais nom est indiqué, qu'il rejette correctement une liste de comptes vide passée sur la ligne de commande.

Vous pouvez télécharger un fichier avec un grand nombre de comptes.

S'il vous reste du temps

La gestion des erreurs dans votre programme peut être simplifiée grâce à l'utilisation de crate dédiée à cela comme eyre. Toutes les erreurs implémentant std::error::Error peuvent être converties en eyre::Report, notamment les erreurs système comme std::io::Error. Il vous suffit d'avoir votre propre énumération error::Error implémentant std::error::Error pour pouvoir utiliser systématiquement eyre::Report dans vos types de retour. Vous n'avez plus à vous occuper de la conversion des autres erreurs dans votre type énuméré, seules vos erreurs spécifiques sont à gérer.

Vous pouvez même obtenir des erreurs en couleur en ajoutant le crate color_eyre et en appelant color_eyre::install() au début de votre programme principal.

Lifetimes

On dispose d'une liste de comptes sous la forme d'un vecteur d'éléments de type Account dans la variable accounts. Lorsqu'on cherche à les grouper, on retourne une table de hashage (HashMap) indexée par des String et contenant des vecteurs de String, c'est-à-dire qu'une copie des données contenues dans account est effectuée. Ne pourrait-on pas faire mieux ? Tant que accounts existe, ne pourrait-on pas pointer simplement sur les chaînes de caractères contenues dans ce vecteur ?

Changement de signature de Account::group()

On souhaite changer la signature de Account::group() pour qu'elle devienne

pub fn group(accounts: &[Account]) -> HashMap<&str, Vec<&str>> { … }

ce qui correspond, en appliquant les règles d'élisions, à :

pub fn group<'a>(accounts: &'a [Account]) -> HashMap<&'a str, Vec<&'a str>> { … }

Autrement dit, les données dans la table de hashage et la table de hashage elle-même ne seront accessibles que tant que la variable accounts passée en argument de group() sera valable. Cela est garanti à la compilation et il sera impossible de s'en affranchir.

  1. Changez la définition de group() pour correspondre à celle indiquée ci-dessus ainsi que son contenu.
  2. Adaptez le programme principal pour qu'il passe une référence sur accounts à group() au lieu de consommer la variable.

Après ces modifications, plus aucune copie de chaîne n'a lieu lors de la détermination des duplicatas. Les seules copies sont des copies de références, c'est-à-dire de pointeurs assorties de tailles. De plus, le vecteur accounts initial n'a pas été consommé, juste référencé, ce qui signifie qu'on pourra faire des opérations complémentaires sur les comptes originels.

Parallélisme

On souhaite vérifier que les mots de passe de ces comptes n'ont pas déjà été retrouvés dans des fuites de données connues. Pour cela, on va utiliser le service Have I been pwned? et notamment son API pour vérifier des mots de passe.

Le principe de cette API est simple : afin de ne pas transmettre le mot de passe à tester sur le réseau, on ne transmet que les 5 premiers caractères de la représentation hexadécimale du hash SHA-1 du mot de passe dans une URL. La réponse contient alors la liste des hashs des mots de passe (sans les 5 premiers caractères) ayant été publiés après des fuites de données ainsi que le nombre d'occurrences.

Par exemple, le SHA-1 du mot de passe password est 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8. Dans la page https://api.pwnedpasswords.com/range/5BAA6 on trouve 1E4C9B93F3F0682250B6CF8331B7EE68FD8:3861493, ce qui signifie que le mot de passe "password" est associé à presque 4 millions de comptes qui ont fuité.

Le calcul du hash étant gourmand en puissance CPU, nous souhaitons répartir le calcul des hashs des mots de passe associés à nos comptes sur l'ensemble des cœurs de notre système. Les étapes seront :

  • Pour chaque compte, calculer le SHA-1 de son mot de passe.
  • Regrouper les comptes ayant les même 5 premiers caractères de SHA-1 (le préfixe).
  • Récupérer les pages contenant les compléments (suffixes) de SHA-1 pour chacun de ces préfixes.
  • Rechercher dans ces pages les suffixes de nos comptes.
  • Afficher les comptes vulnérables triés par nombre d'occurrences.

Calcul d'un SHA-1

Le crate sha1 permet de faire facilement des calculs de SHA-1.

  1. Ajoutez le crate sha1 à votre Cargo.toml. Vous pouvez utiliser cargo add sha1, qui ajoutera la ligne suivante aux dépendances dans Cargo.toml :
[dependencies]
sha1 = "1.0.0"
  1. Créez un nouveau module hibp (pour Have I been pwned?) dans votre projet.

  2. Créez une fonction hibp::sha1() qui à partir d'une référence sur un compte renvoie le préfixe (5 caractères) et le suffixe (après 5 caractères) de la représentation hexadécimale en majuscules du SHA-1.

fn sha1(account: &Account) -> (String, String) {
  todo!()
}

Vous devriez trouver toutes les informations nécessaires dans la documentation du crate sha1. Pour la conversion du SHA-1 en hexadécimal, vous pouvez utiliser le fait que GenericArray<u8, T> implémente le trait UpperHex et ainsi utiliser format!.

Calcul multi-cœurs des SHA-1

Pour effectuer les calculs de SHA-1 en parallèle, nous allons utiliser le crate rayon. Celui-ci fournit notamment des extensions sur les types classiques permettant d'obtenir des itérateurs parallèles qui exploitent automatiquement tous les cœurs de la machine courante.

  1. Ajoutez rayon comme dépendance à votre Cargo.toml.
  2. Dans votre module hibp, importez le prélude de rayon qui fournira toutes les fonctions et extensions utiles, rayon::prelude::*.
  3. Écrivez une fonction hibp::all_sha1() qui calcule en parallèle tous les préfixes et suffixes des SHA-1 des comptes passés en paramètre en utilisant la fonction hibp::sha1() définie précedemment :
fn all_sha1(accounts: &[Account]) -> Vec<(String, String, &Account)> {
  todo!()
}

Dans cette fonction, vous serez probablement amenés à utiliser la méthode d'extension par_iter() fournie par rayon en lieu et place de iter().

Test des performances

Pour pouvoir comparer les performances avec et sans rayon, nous allons mettre en place le début de l'infrastructure permettant d'ajouter notre nouvelle commande à notre programme principal.

  1. Écrivez une fonction publique hibp::all_sha1_timed() avec la même signature que hibp::all_sha1() qui affiche le nombre de micro-secondes écoulées dans cette fonction avant d'en retourner le résultat. Vous pourrez utiliser les types std::time::{Instant, Duration} qui représentent respectivement un point dans le temps (opaque) et une durée, obtenue par exemple en soustrayant deux points dans le temps. Grâce à Instant::now() vous pouvez capturer la date courante, et avec la méthode as_micros() sur une valeur de type Duration vous obtiendrez sa durée en microsecondes.

  2. Dans votre programme principal, ajoutez une sous-commande hibp. Celle-ci devra prendre en paramètre obligatoire un fichier. Les comptes seront chargés depuis ce fichier, puis hibp::all_sha1_timed() sera invoquée. On verra donc le temps de calcul des SHA-1.

  3. Essayez de remplacer par_iter() par iter() et comparez les temps de traitement.

Regroupement des SHA-1 de même préfixe

  1. Implémentez la fonction hibp::sha1_by_prefix() qui à partir des comptes et en utilisant les méthodes précédentes regroupe au sein d'une table de hashage les comptes dont le SHA-1 a le même préfixe et accompagne chaque compte de son suffixe :
fn sha1_by_prefix(accounts: &[Account]) -> HashMap<String, Vec<(String, &Account)>> {
  todo!()
}

Par exemple, on prendra l'exemple fictif supposant que :

  • Le compte "elba" a comme mot de passe "BWE!y4xj:i" et son SHA-1 est 8E7336BBDBE0B7F31DE9A06B053ECDF5E356A946.
  • Le compte "dyanna" a comme mot de passe "NvLK25b+" et son SHA-1 est EF867658E9572AC965BBDC5212FAE656570DB09B.
  • Le compte "tahira" a comme mot de passe "xQS?" et son SHA-1 est 8E7334AB061EAB6673D2C591D6C77CEEE12DE961.

Dans ce cas, la fonction hibp::sha1_by_prefix() renverra, en pseudo-notation :

{
  "8E733": [
    ("6BBDBE0B7F31DE9A06B053ECDF5E356A946",
     Account { login: "elba", password: "BWE!y4xj:i" }),
    ("4AB061EAB6673D2C591D6C77CEEE12DE961",
     Account { login: "tahira", password: "xQS?" })
  ],
  "EF867": [
    ("658E9572AC965BBDC5212FAE656570DB09B",
     Account { login: "dyanna", password: "NvLK25b+" }),
  ],
}

Récupération des suffixes

Nous allons maintenant récupérer les suffixes correspondant à chaque préfixe depuis l'API v3 du site Have I been pwned?. Nous allons utiliser le crate reqwest pour faire des requêtes simples en mode bloquant.

  1. Ajoutez le crate reqwest avec la feature blocking à Cargo.toml.

Gestion des erreurs

Nous allons devoir gérer deux types d'erreurs supplémentaires :

  • des erreurs de type reqwest::Error en cas de problème de communication ;
  • des erreurs de type std::num::ParseIntError en cas de problème de parsing du résultat de l'API, car les lignes contiennent un suffixe suivi de deux points suivi d'un nombre qui représente le nombre d'occurrences, si celui-ci est incorrect cette erreur sera signalée.
  1. Enrichissez le type error::Error de deux alternatives ReqwestError et ParseIntError qui encapsulent les deux erreurs détaillez ci-dessus.

Accès réseau

Nous allons utiliser la méthode reqwest::blocking::get() du crate reqwest qui permet d'effectuer une requête GET vers l'API. Celle-ci peut signaler une erreur lors du get() lui-même ainsi que lors de l'utilisation de la méthode text() pour récupérer le contenu de la réponse.

  1. Ajoutez la fonction hibp::get_page() qui fait une requête à https://api.pwnedpasswords.com/range/PREFIX (où PREFIX est à remplacer par les 5 premiers caractères du SHA-1 en majuscules) et renvoie les lignes de la réponse :
fn get_page(prefix: &str) -> Result<Vec<String>, Error> {
  todo!()
}

On s'intéressera notamment à la méthode lines() sur les chaînes de caractère qui fournit un itérateur bien pratique.

  1. Ajoutez la fonction hibp::get_suffixes() qui, en utilisant la méthode précédente, renvoie à partir d'un préfixe une table de hashage précisant pour chaque suffixe trouvé dans la page le nombre d'occurrences trouvées dans des fuites de données. On rappelle que chaque ligne de la page renvoyée par l'API est de la forme suffixe:N où N est un entier représentant le nombre d'occurrences.
fn get_suffixes(prefix: &str) -> Result<HashMap<String, u64>, Error> {
  todo!()
}

La méthode parse::<T>() permet de transformer une chaîne de caractère en un autre type, notamment un type entier T. Dans ce cas, parse() renvoie un Result<T, ParseIntError>. On peut également utiliser u64::from_str_radix() qui renvoie le même type.

Mise bout à bout

Nous avons maintenant tous les composants nécessaires pour pouvoir vérifier si nos mots de passe sont identiques à des mots de passe retrouvés dans des fuites de données.

  1. Écrivez une fonction publique hibp::check_accounts() prenant des comptes en paramètre et renvoyant une liste de comptes et du nombre d'occurrences où les mots de passe associés ont été trouvés dans des fuites de données. Cette liste devra être ordonnée du plus fréquent au moins fréquent.
pub fn check_accounts(accounts: &[Account]) -> Result<Vec<(&Account, u64)>, Error> {
  todo!()
}

Cette fonction utilisera tout d'abord hibp::sha1_by_prefix() pour regrouper les comptes par préfixe de SHA-1. Elle invoquera ensuite hibp::get_suffixes() sur chaque préfixe pour récupérer les suffixes associés, éventuellement de manière parallèle. S'il n'y a pas eu d'erreur dans cette phase, au sein de chaque préfixe, on pourra ajouter chaque compte au vecteur résultat en l'accompagnant du nombre d'occurrences ou de 0 si le suffixe du compte n'est pas présent.

Après avoir trié le vecteur résultat (à l'aide par exemple de la méthode sort_unstable_by_key()), on pourra le renvoyer.

  1. Appelez la fonction hibp::check_accounts() depuis le programme principal au sein de la sous-commande hibp.

Bravo ! Vous avez maintenant implémenté une suite complète de vérification d'une liste de comptes. N'hésitez pas à l'utiliser avec vos propres mots de passe si vous souhaitez les tester en masse, par exemple en les exportant de votre gestionnaire de mots de passe.

Note importante

Attention à ne pas surcharger l'API de Have I been pwned? par des requêtes trop nombreuses. Si vous avez récupéré le fichier accounts.txt donné en exemple, sélectionnez en un petit nombre d'entrées pour faire vos tests, par exemple les 100 premières :

$ head -n 100 accounts.txt > small.txt

Vous pouvez ensuite utiliser le fichier small.txt comme fichier de test, ou en construire un autre à la main.

S'il vous reste du temps

S'il vous reste du temps, vous pouvez améliorer votre programme en utilisant le crate indicatif pour afficher un indicateur d'avancement.

Programmation asynchrone

Nous allons introduire de la programmation asynchrone dans notre programme. Si votre programme précédent est dans un état compilable, nous allons le compléter, sinon il vous suffit de recréer un nouveau projet (en choisissant un nouveau nom) comme indiqué ici.

Choix de l'exécutif asynchrone

Plusieurs exécutifs asynchrones existent : tokio, async-std, etc. Nous utiliserons ici tokio.

  1. Ajoutez le crate tokio aux dépendances de votre projet, avec la feature full pour bénéficier de l'exécutif multi-cœurs.
  2. Ajoutez-y également le crate futures qui fournit des extensions utiles sur les types implémentant les traits Future et Stream.
  3. Lancez automatiquement l'exécutif de tokio en déclarant la fonction main() asynchrone et en lui associant l'attribut tokio::main :
#[tokio::main]
async fn main() -> Result<(), Error> {
  …
}

Dès lors, main() s'exécute dans un contexte async et peut utiliser await sur des fonctions et des blocs async. De même, les fonctionnalités spécifiques à tokio peuvent être utilisées étant donné que nous sommes dans le contexte d'un exécutif tokio.

Scanner de ports TCP

Nous allons réaliser un scanner de ports TCP. Celui-ci tentera de tester des ports sur des machines cibles et de déterminer s'ils sont ouverts ou non, et quel logiciel s'exécute derrière.

Le code du scanner se trouvera dans le module scanner de notre programme et dans ses sous-modules.

  1. Créez le module scanner. Étant donné qu'il aura des sous-modules, créez le dans le fichier src/scanner/mod.rs plutôt que dans src/scanner.rs afin de regrouper le module et ses sous-modules dans le même dossier.
  2. Créez un module scanner::net dans le fichier src/scanner/net.rs. Nous y mettrons le code spécifique aux interactions réseau.

Utilisation du type TcpStream

La bibliothèque standard de Rust fournit un type std::net::TcpStream qui représente un flux TCP connecté entrant ou sortant. Le crate tokio fournit un type similaire, tokio::net::TcpStream, dont l'interface est proche mais qui a comme caractéristique d'être entièrement asynchrone, c'est-à-dire que toutes les opérations retournent immédiatement, en général à travers un type implémentant Future ou Stream.

Le type tokio::net::TcpStream implémente les deux traits tokio::io::AsyncReadExt et tokio::io::AsyncWriteExt qui fournissent de nombreuses méthodes utilitaires telles que read_to_string() ou write_all(). Il est également possible de séparer le stream en deux parties, l'une en lecture et l'autre en écriture, pour les utiliser indépendamment.

  1. Implémentez une fonction asynchrone scanner::net::tcp_ping() qui renverra s'il est possible de se connecter à un port donné :
pub async fn tcp_ping(host: &str, port: u16) -> bool {
  todo!()
}
  1. Après avoir mis les droits nécessaires pour permettre à main() d'atteindre cette fonction, rajoutez une sous-commande ping qui prend un hôte et un numéro de port sur la ligne de commande et qui indique si le port est ouvert ou non, en fonction de si le TcpStream parvient ou non à se connecter.

On notera que TcpStream::connect() prend de nombreux formats d'arguments en entrée dont un couple (host, port).

$ cargo run -- ping 127.0.0.1 22
127.0.0.1:22 is open
$ cargo run -- ping 127.0.0.1 23
127.0.0.1:23 is closed
$ cargo run -- ping google.com 80
google.com:80 is open

Détection d'adresses non résolues

Cependant, notre code a un problème : l'impossibilité de se connecter n'est pas la seule cause d'erreur, il se peut que la cible ne soit pas trouvée.

$ cargo run -- ping google.kfdslkfj 80
google.kfdslkfj:80 is closed

Il serait légitime ici de signaler une erreur. Nous pouvons utiliser la fonction asynchrone tokio::net::lookup_host() qui, avec les mêmes arguments que TcpStream::connect(), renvoie les adresses à utiliser pour l'hôte recherché ou une erreur si elle n'en trouve pas. En cas d'erreur, elle retourne une erreur de type std::io::Error que nous pouvons encapsuler dans notre type error::Error.

  1. Modifiez la signature de la fonction scanner::net::tcp_ping() et son appel dans main() pour qu'elle renvoie une erreur si l'adresse sélectionnée n'existe pas :
pub async fn tcp_ping(host: &str, port: u16) -> Result<bool, Error> {
  todo!()
}

On utilisera avec profit l'idiomatique construction .await? qui combine .await pour attendre le résultat d'une Future et ? pour faire remonter de manière prématurée une erreur avec un appel implicite à .into() vers le type d'erreur attendu au niveau supérieur.

Cela donnera maintenant :

$ cargo run -- ping google.kfdslkfj 80
Error: IoError(Custom { kind: Uncategorized, error: "failed to lookup address information: Name or service not known" })

Bien entendu, rien n'empêche d'être plus agréable dans l'affichage du message d'erreur en remplaçant le await? dans main() par un match un peu plus respectueux de l'utilisateur, afin d'obtenir par exemple un message plus explicite si le type d'erreur encapsulée est std::io::Error dont il est connu que les descriptions sont pertinentes :

$ cargo run -- ping google.kfdslkfj 80
google.kfdslkfj: failed to lookup address information: Name or service not known

Timeout

L'utilisation d'une adresse non routable peut mener à des temps d'attente excessifs :

$ cargo run -- ping 10.101.102.103 80
[attente interminable]

Le crate tokio fournit une fonction utilitaire tokio::time::timeout qui enveloppe une Future dans un délai. Le résultat arrive enveloppé dans un Result, avec une erreur signalée en cas de dépassement du temps maximal.

  1. Modifiez le code de scanner::net::tcp_ping() pour qu'un délai maximal de 3 secondes soit utilisé. Une nouvelle valeur de l'énumération error::Error::Timeout permettra de représenter cette erreur spécifique.

Si vous avez modifié le code de main() pour représenter plus clairement les erreurs de résolution de nom, faîtes de même en cas de timeout.

$ cargo run -- ping 10.101.102.103 80
10.101.102.103:80 timed out

Scanner en parallèle

Nous souhaitons effectuer plusieurs scans en parallèle. Pour cela, nous allons modifier notre sous-commande ping pour qu'elle accepte une liste d'hôtes ainsi qu'une liste de ports, pour pouvoir faire par exemple :

$ cargo run -- ping www.enst.fr,google.com,localhost 80,443,22
google.com:22 timed out
google.com:80 is open
google.com:443 is open
localhost:22 is open
localhost:80 is closed
localhost:443 is closed
www.enst.fr:22 timed out
www.enst.fr:80 is open
www.enst.fr:443 is open
  1. Modifiez la sous-commande ping pour qu'elle récupère une liste d'hôtes séparés par des virgules ainsi qu'une liste de ports séparés par des virgules.

  2. Implémentez une fonction scanner::net::tcp_ping_many() qui accepte une liste d'hôtes et une liste de ports et effectue en parallèle un appel à scanner::net::tcp_ping().

async fn tcp_ping_many<'a>(targets: &[(&'a str, u16)])
    -> Vec<(&'a str, u16, Result<bool, Error>)>
{
  todo!()
}

Pour chaque couple (host, port), cette fonction renverra le résultat de tcp_ping() dans un triplet rappelant également l'hôte et le port. Vous remarquerez qu'on a dû nommer la lifetime des chaînes de caractères avec un paramètre générique 'a. Sans cela, le compilateur n'aurait pas pu choisir entre la lifetime de la slice ou celle des données contenues dans la slice pour affecter une lifetime aux &str dans le type de retour.

Ci-dessous, on trouvera des conseils pour implémenter l'exécution parallèle des différentes connexions.

Outils disponibles pour l'exécution parallèle

Pour l'exécution parallèle, on peut opter (entre autre) pour deux mécanismes différents :

  • À partir d'un stream contenant des Future, on peut utiliser buffer_unordered() pour exécuter les Future en provenance du stream en limitant le nombre de Future non terminées qui s'exécutent en parallèle.
  • À partir d'un itérateur sur des Future, on peut construire un futures::stream::FuturesUnordered qui implémente Stream et fournit les résultats des futures au fur et à mesure qu'ils sont disponibles.

Ces deux mécanismes sont détaillés ci-dessous.

FuturesUnordered

Ce type permet de regrouper des Future et de les exécuter en parallèle mais renvoie les résultats sous la forme d'un Stream qu'on lira de manière asynchrone. Ce FuturesUnordered peut être construit grâce à collect() sur un itérateur contenant des Future.

Une fois ce type FuturesUnordered construit, on peut appeler collect() (disponible dans le trait futures::stream::StreamExt) pour collecter le contenu de ce stream dans une collection qui sera disponible de manière asynchrone. On fera donc la succession d'opérations :

itérateur sur (host, port)
  -> itérateur sur des futures contenant chacune (host, port, résultat)
  -> FuturesUnordered qui un est stream produisant des (host, port, résultat)
  -> future contenant un vecteur avec tous les (host, port, résultat)

On s'aperçoit que le dernier type est exactement ce qu'on veut renvoyer depuis la fonction asynchrone tcp_ping_many().

Attention toutefois : il y a un risque de dépasser le nombre de descripteurs de fichiers pouvant être ouvert par le programme courant. Dans ce cas, il ne sera pas possible de créer d'autres TcpStream.

buffer_unordered()

Cette fonction qui s'applique à un stream de Future prend en paramètre le nombre de Future non terminées qui s'exécutent en parallèle. Cela permet donc de limiter le nombre d'exécutions parallèle, par exemple pour ne pas dépasser le nombre de descripteurs de fichier disponibles pour notre programme. Les opérations peuvent être schématisées par :

stream sur (host, port)
  -> stream sur des futures contenant chacune (host, port, résultat)
  -> .buffer_unordered(N) qui produit un stream avec (host, port, résultat)
  -> .collect() : future contenant un vecteur avec tous les (host, port, résultat)

Là aussi on obtient le type attendu. C'est le mécanisme conseillé pour implémenter le parallélisme dans le cas présent.

  1. Implémentez une fonction scanner::net::tcp_mping() qui prend une liste d'hôtes et liste de ports, qui construit une liste de (host, port) cible et qui appelle tcp_ping_many() sur cette liste.
pub async fn tcp_mping<'a>(targets: &[&'a str], ports: &[u16])
    -> Vec<(&'a str, u16, Result<bool, Error>)>
{
  todo!()
}
  1. Modifiez le programme principal pour qu'il appelle tcp_mping() au lieu de tcp_ping(). Changez la visibilité de tcp_ping() qui n'a plus de raison d'être pub.

Vous remarquerez en exécutant un exemple similaire à celui se trouvant en haut de cette page que le temps d'attente maximal est de 3 secondes. En effet, chaque tentative de connexion s'exécute en parallèle et génère un timeout au bout de 3 secondes.

Notation CIDR

La notation CIDR permet de désigner de manière concise un ensemble d'adresses IPv4 en indiquant le nombre de bits qu'on ne fait pas varier. Par exemple, 137.194.2.16 et 137.194.2.16/32 sont équivalents et ne désignent qu'une seule adresse IP. Par contre, 137.194.2.0/24 représente toutes les adresses de la forme 137.194.2.xx varie de 1 à 254 (0 et 255 étant réservées dans ce cas à l'adresse de réseau et à l'adresse de broadcast).

On souhaite pouvoir utiliser la notation CIDR dans notre spécification d'hôtes, comme dans la commande suivante :

$ cargo run -- ping 192.168.0.169/30 22,80
192.168.0.169:22 is open
192.168.0.169:80 is closed
192.168.0.170:22 timed out
192.168.0.170:80 timed out

(on remarquera que les adresses de réseau 192.168.0.168 et de broadcast 192.168.0.171 ont été automatiquement exclues du scan)

Le crate ipnet permet de manipuler la notation CIDR.

  1. Ajoutez le crate ipnet à vos dépendances dans Cargo.toml.
  2. Écrivez une fonction scanner::expand_net() qui prend une spécification en entrée et qui renvoie une liste d'hôtes :
pub fn expand_net(host: &str) -> Vec<String> {
  todo!()
}

Si jamais le paramètre host est une spécification CIDR, la liste des adresses IPv4 concernées devra être renvoyée. Si jamais ce n'est pas une spécification CIDR ou si c'est un nom de machine sous forme textuelle (comme www.telecom-paris.fr), ce même nom sera renvoyé.

Utilisez pour cela les fonctionnalités du crate ipnet : vous trouverez de quoi construire un Ipv4Net qui représente un sous-réseau. En cas d'échec, vous saurez que n'êtes pas en présence d'une notation CIDR. En cas de succès, vous pourrez énumérer les adresses IPv4 appartenant à ce sous-réseau.

  1. Dans le programme principal, appelez scanner::expand_net() sur chacun des hôtes passés sur la ligne de commande et construisez ainsi la liste des cibles. Utilisez cette liste dans l'appel à scanner::net::tcp_mping().

Vous allez rapidement vous apercevoir que la sortie est illisible en raison du grand nombre de timeouts et de ports fermés.

  1. Ajoutez une option -o/--open-only pour n'afficher que les ports ouverts que vous avez trouvés.

On peut maintenant faire :

$ cargo run -- ping -o 192.168.0.0/24 22,80,443
192.168.0.16:80 is open
192.168.0.36:22 is open
192.168.0.138:80 is open
192.168.0.138:443 is open
192.168.0.138:22 is open
192.168.0.169:22 is open
192.168.0.254:443 is open
192.168.0.254:80 is open
192.168.0.96:80 is open

Identification

Nous souhaitons identifier les logiciels tournant sur certains ports. Plutôt que de nous contenter d'ouvrir une connexion, nous allons maintenant retourner une valeur indiquant la ligne d'entête renvoyée par le service sur lequel nous nous sommes connecté si elle arrive dans un temps raisonnable :

$ cargo run -- ping -o www.free.fr,smtp.free.fr,imap.free.fr,ftp.free.fr 21,25,80,143,587
ftp.free.fr:21 is open: 220 Welcome to ProXad FTP server
imap.free.fr:143 is open: * OK IMAP4 ready
smtp.free.fr:25 is open: 220 smtp3-g21.free.fr ESMTP Postfix
smtp.free.fr:587 is open: 220 smtp3-g21.free.fr ESMTP Postfix
www.free.fr:80 is open

Le résultat est cohérent : le serveur FTP (port 21), le serveur IMAP (port 143) et les serveurs SMTP (port 25 et port 587) s'annoncent spontanément. Par contre le serveur HTTP (port 80) n'enverra une réponse que si on envoie une requête.

  1. Ajoutez un type scanner::IdentificationResult contenant trois alternatives : WelcomeLine(String), NoWelcomeLine et ConnectionRefused.
  2. Écrivez une fonction asynchrone scanner::net::welcome_line() qui prend un tokio::net::TcpStream en paramètre et qui renvoie un IdentificationResult contenant soit WelcomeLine(…) si le serveur a envoyé une ligne de bienvenue dans la première seconde, ou NoWelcomeLine dans tous les autres cas.

Bien entendu, si on n'a jamais obtenu de TcpStream car la connexion a échoué, on renverra ConnectionRefused.

Vous pouvez enrichir votre stream de capacités de lecture plus puissantes en l'encapsulant dans un tokio::io::BufReader qui implémente le trait tokio::io::AsyncBufRead. Ce trait est étendu par le trait d'extension tokio::io::AsyncBufReadExt. On notera également qu'il est facile d'éliminer d'éventuels caractères de fin de ligne parasites avec des fonctions existantes de str.

  1. Modifiez la fonction tcp_ping() pour qu'elle appelle welcome_line() si la connexion au serveur distant réussi. Le résultat devra être modifié pour renvoyer un IdentificationResult à la place du bool précédemment utilisé.

  2. Modifiez vos fonctions tcp_ping(), tcp_ping_many() et tcp_mping() pour propager de la même manière ce résultat.

  3. Modifiez le programme principal pour qu'il affiche la ligne d'identification quand elle est disponible comme dans l'exemple ci-dessus.

S'il vous reste du temps

Tentative d'identification active

Si jamais aucune ligne d'identification n'est envoyée par un serveur au bout d'une seconde, il peut être intéressant de tester s'il s'agit d'un serveur web afin notamment de voir s'il répond avec un header Server qui permet d'identifier le logiciel qui tourne. La manière de procéder pourrait être :

  • Envoyer la chaîne "GET / HTTP/1.0\r\n\r\n" qui est une requête HTTP/1.0 basique qui ne précise pas le nom de l'hôte.
  • Lire les lignes renvoyées par le serveur, et dans les headers (lignes avant la première ligne vide) identifier la ligne commençant par "Server:" et la renvoyer.

Bien entendu un timeout d'une seconde devrait également être configuré autour de l'ensemble de cette opération.

Amélioration du programme précédent

Dans le programme qui vérifie sur Have I been pwned? si les mots de passe de nos comptes ont déjà fait partie d'une fuite de données, nous utilisons des fonctions bloquantes du crate reqwest, ce qui limite nos performances puisque nous récupérons les pages de suffixes de SHA-1 séquentiellement ou presque (paralléliser par le nombre de cœur d'un ordinateur portable n'est pas une grosse parallélisation pour des entrées-sorties réseau).

En utilisant l'API non-bloquante de reqwest et les streams, notamment grâce à la méthode buffered de futures::stream::StreamExt, vous pouvez effectuer un plus grand nombre de requêtes simultanées à l'API de Have I been pwned?. Les performances devraient en être grandement améliorées.

Important : n'utilisez pas FuturesUnordered ou FuturesOrdered qui ne limiteraient pas le nombre de connexions simultanées. La méthode buffered vous permet de préciser le nombre de requêtes "en vol" simultanément. Utilisez une valeur qui vous semble raisonnable.

Programmation multi-cœurs

Nous allons implémenter un buffer circulaire qui permet à un lecteur et un écrivain d'échanger des données ordonnées sans utiliser de verrou ni de mémoire allouée dynamiquement.

Ce buffer circulaire offre les possibilités suivantes :

  • L'initialisation se fait à partir d'un tableau de données non initialisées ou à partir d'un pointeur brut et d'une longueur qui représentent l'espace de stockage utilisé par le buffer circulaire.
  • Le buffer est utilisable par un lecteur et un écrivain à la fois, aucun ne pouvant bloquer l'autre.
  • Lorsqu'on veut ajouter des éléments, ils sont soit tous rajoutés soit aucun en cas de manque de place. Des éléments multiples peuvent être ajoutés avec push_all() à partir de n'importe quel itérateur qui implémente ExactSizeIterator afin de pouvoir procéder à la vérification.
  • Les éléments sont soit récupérés un par un avec pop().
  • Deux méthodes execute() et execute_max() permettent d'appliquer une fonction à une vue partielle du buffer circulaire. Les éléments sont présentés sous la forme d'une référence sur une slice et sont considérés pouvoir être libérés une fois que la fonction retourne.
  • Les références sur le buffer implémentent Iterator et renvoient les éléments disponibles.

Étant donné la taille du code, vous disposez d'un projet disponible ici. Ce projet est à compléter partout où se trouvent des todo!(). Des tests permettent de vérifier le bon comportement du buffer circulaire une fois les fonctions manquantes implémentées.

Fonctionnement du buffer circulaire

Le buffer circulaire fonctionne avec deux indices, read_index et write_index. Il faut le voir comme une bande qui boucle sur elle-même :

   read_index         write_index
       |                   |
       v                   v
WWWWWW.rrrrrrrrrrrrrrrrrrrrWWWWWW  => retour au début

Tout ce qui est marqué r, de read_index à write_index (non compris), représente une donnée présente dans le buffer qui peut être lue. Cela signifie que si read_index == write_index alors il n'y a rien à lire.

Tout ce qui est marqué W, de write_index à read_index - 1 (non compris), est un espace dans lequel il est possible d'écrire. Si write_index == read_index - 1 (modulo la capacité du buffer), il n'y a plus de place pour écrire.

L'endroit marqué . ne peut pas être utilisé car si on y écrivait sans rien lire entre temps on aurait read_index == write_index ce qui serait compris comme un buffer vide. On peut donc stocker jusqu'à capacity - 1 valeurs dans notre buffer circulaire à un moment donné.

Votre travail

Les champs read_index et write_index, qui sont des AtomicUsize, sont les seuls points de synchronisation entre le lecteur et l'écrivain. C'est à travers ces deux champs et les modes d'écriture atomique qu'on sera sûr de lire une donnée dont l'écriture est bien visible dans le thread qui lit et qu'on sera sûr d'écrire à un endroit qui ne contient plus rien d'utile dans le thread qui écrit.

Ajout du code manquant

Méthodes

Voici quelques remarques supplémentaires sur les méthodes à remplir.

write_indices()

La zone dans laquelle nous pouvons écrire les données peut être segmentées en deux parties en raison du caractère circulaire du buffer. Comme on le voyait sur le schéma d'explication présent sur la page précédente, il peut y avoir une zone qui va de write_index jusqu'à la fin du buffer suivie d'une zone qui va du début du buffer jusqu'à read_index - 1 (non compris).

La méthode write_indices() nous retourne en premier élément les indices où on doit écrire en premier les données entrantes et en second la zone supplémentaire de début de buffer si elle existe. On pourra distinguer 4 situations, à savoir successivement read_index > write_index, read_index == 0, read_index == 1 ou le cas restant.

Attention à bien utiliser les bons modes pour accéder aux indices : en retournant de write_indices(), il faut que les endroits dans lesquels on va ensuite écrire ne contiennent bien plus rien d'utile.

push_all()

En utilisant write_indices(), nous pouvons maintenant écrire les données qui nous sont passées en paramètre dans notre buffer circulaire. À la fin de l'écriture, il faudra penser à mettre à jour write_index en utilisant le bon mode.

pop()

Rien de particulier à dire, si ce n'est qu'il faut s'assurer d'utiliser les bons modes pour les différentes variables atomiques qu'on utilisera, à la fois pour être sûr que la valeur lue est bien visible mais aussi que la place libérée l'est avant d'annoncer qu'elle est libre.

read_indices()

Cette méthode renvoie la zone contigüe dans laquelle on peut lire des données. Étant donné qu'on utilisera cette méthode pour présenter une slice à l'utilisateur, nous ne sommes pas intéressés pour l'instant par une seconde zone en début de buffer car ces zones n'étant pas contigües nous ne les présenterons pas en une seule *slice. On n'aura donc que deux cas à considérer. Attention là aussi aux modes, il faut avoir la garantie que les données écrites dans cette zone seront visible dès lors qu'on retournera de cette fonction.

mark_read()

Cette méthode marque comme lues les premières entrées à lire et donc on garantit qu'elles sont contigües. On pourrait implémenter mark_read() comme une boucle de pop(), mais cela serait inefficace de mettre à jour read_index à chaque élément.

Attention à bien détruire chaque élément qui est lu.

drop()

En cas de destruction du buffer, il faut s'assurer de détruire les valeurs non lues qui restent à l'intérieur.

Tests

Des tests sont fournis avec le squelette. Ils vérifieront que les fonctions de base du buffer circulaire fonctionnent comme on l'attend, mais également que les éléments sont bien détruits au bon moment (et seulement à ce moment là).

On pourra remarquer dans le test tests/drop.rs l'utilisation du crate serial_test. Celui-ci fournit un nouvel attribut #[serial] qui permet de sérialiser les tests qui l'utilisent. Cela est nécessaire car nous utilisons un compteur d'instances global et si les tests tournaient en parallèle nous ne pourrions pas vérifier de manière cohérente que le nombre d'instances vivantes à un moment donné correspond bien à ce que nous prévoyons.

Embassy

Dans cette partie du TP, nous allons utiliser Embassy, un framework pour les applications Rust embarquées utilisant la programmation asynchrone.

L'utilisation de la programmation asynchrone permet une écriture souvent plus intuitive qu'avec un intergiciel classique. Cet exemple tiré de la comparaison d'Embassy avec FreeRTOS illustre la facilité d'utilisation du HAL asynchrone :

#[embassy::task]
async fn my_task(mut button: ExtiInput<'static, PC13>) {
    loop {
        button.wait_for_rising_edge().await;
        info!("Pressed!");
        button.wait_for_falling_edge().await;
        info!("Released!");
    }
}

Dans cet exemple, l'attente du changement d'état du bouton poussoir connecté sur PC13 se fait par interruption sans qu'on ait à le demander explicitement. L'interruption 13 de l'EXTI est également acquittée automatiquement. Cela signifie notamment que le cœur du microcontrôleur peut entrer en veille s'il n'a rien d'autre à faire en attendant que le bouton poussoir soit actionné.

Un autre exemple complet, tiré d'un tutoriel sur Embassy, montre la facilité d'utilisation d'une UART en utilisant des transferts DMA entre la mémoire et le périphérique sans monopoliser le processeur :

#![no_std]
#![no_main]
#![feature(type_alias_impl_trait)]

use embassy_executor::Spawner;
use embassy_stm32::interrupt;
use embassy_stm32::usart::{Config, Uart};
use panic_halt as _;

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());

    let irq = interrupt::take!(USART2);
    let mut usart = Uart::new(
        p.USART2,
        p.PA3,
        p.PA2,
        irq,
        p.DMA1_CH6,
        p.DMA1_CH5,
        Config::default(),
    );

    usart.write(b"Starting Echo\r\n").await.unwrap();

    let mut msg: [u8; 8] = [0; 8];

    loop {
        usart.read(&mut msg).await.unwrap();
        usart.write(&msg).await.unwrap();
    }
}

On pourra se remémorer les différentes étapes à effectuer lorsqu'on utilise habituellement de telles fonctions. Là aussi le cœur du microcontrôleur se mettra en veille en attendant que chaque message soit lu ou transmis depuis le port série.

Toutes ces fonctions utilisent un HAL asynchrone adaptée à l'architecture ciblée. Dans notre cas, cela sera le HAL pour STM32.

💡 Il est important dans la documentation du HAL de choisir (sur la ligne du haut) le modèle de microcontrôleur approprié. Ici, nous utiliserons un stm32l475vg, disponible sur nos cartes IoT node.

Prise en main

Téléchargez Embassy depuis le dépôt git.

Essayez quelques exemples dans le dépôt examples/stm32l4. Attention, ces exemples ne sont pas prévus pour notre carte, il faut donc les adapter :

  • Dans Cargo.toml, choisissez la feature qui correspond à votre microcontrôleur pour le crate embassy-stm32.
  • Configurez le bon microcontrôleur dans la section [runner] de .cargo/config.toml.

Commencez par le binaire blinky, après avoir vérifié que le port utilisé correspond bien à la led de la carte. Essayez ensuite d'autres exemples en les adaptant à chaque fois à la carte.

Si vous voulez essayer les exemples en USB, il vous faudra non seulement un second câble mais également trouver comment configurer les horloges, notre microcontrôleur ne disposant notamment pas d'horloge HSI48. Ce n'est pas une priorité pour l'instant.

Capteurs

Interfacez un capteur en I²C de votre choix et renvoyez le résultat avec defmt en RTT. Pendant ce temps, faîtes clignoter les leds jaune et bleue de manière asynchrone avec la lecture du capteur.

Lorsque le bouton est appuyé, allumez une autre led pendant une seconde puis éteignez la.

Module ISM 43362

La carte est équipée d'un module eS-Wifi ISM 43362. Ce module est connecté au microcontrôleur par un lien série (USART3) et un lien SPI (SPI3). Le module se pilote à l'aide d'un jeu de commandes AT et permet de se connecter à un réseau WiFi et d'établir des connexions TCP ou UDP.

En vous connectant à travers un téléphone, connectez vous à une page web et transférez son contenu en RTT. Vous êtes invité·e à partager les informations au fur et à mesure que vous les découvrirez sur la liste de diffusion.

Note : mise à jour du module ISM 53362

Vous pouvez mettre le module eS-WiFi de votre carte à jour en flashant ce binaire sur votre carte:

$ DEFMT_LOG=debug probe-rs run --chip STM32L475VGT6 flash-iotnode-ism43362.elf

💥☠️💥 N'interrompez pas la mise à jour du module eS-WiFi sous peine de risquer de le briquer.

Macros

Nous allons écrire deux types de macros : des macros simples, qu'on peut écrire avec macro_rules!, et des macros procédurales.

Produit cartésien

  1. Écrivez avec macro_rules! une macro cartesian qui prend en paramètre deux expressions implémentant respectivement IntoIterator<T> et IntoIterator<U>, avec T: Clone et U: Clone. La macro devra s'évaluer en un itérateur sur des objets de types (T, U) avec toutes les combinaisons possibles en provenance des deux itérateurs en faisant varier plus rapidement l'expression la plus à droite.
let prod = cartesian!(
             [1, 2, 3],
             [String::from("foo"), String::from("bar")]
           ).collect::<Vec<_>>();
println!("{all:?}");
[(1, "foo"), (1, "bar"), (2, "foo"), (2, "bar"), (3, "foo"), (3, "bar")]
  1. Étendez cette macro pour qu'elle puisse prendre trois expressions en plus de deux, les expressions à gauche variant plus lentement que les expressions à droite.

Debug

On souhaite écrire une macro similaire à la macro dbg! existante. Pour cela, on pourra utiliser utilement un certain nombre de macros existantes :

  • file!() et line!() contiennent respectivement le fichier et la ligne courants.
  • stringify!() renvoie la représentation littérale de leur argument, sans l'interpréter, sous la forme d'une &'static str.
  1. Écrivez une macro debug!(expression) qui affiche le fichier et la ligne courante, la représentation de l'expression et sa valeur.
fn main() {
  println!("Result = {}", 10 + debug!(2*3));
}

affichera

$ cargo run
main.c:17 `2*3` = 6
Result = 16

Attention : si l'expression a un effet de bord, celui-ci ne doit avoir lieu qu'une seule fois :

fn main() {
  debug!(println!("foobar"));
}

ne doit afficher "foobar" qu'une fois :

$ cargo run
foobar
main.c:23 `println!("foobar")` = ()

Mini-Forth

Nous voulons implémenter un mini-Forth avec des macros sous la forme d'un domain-specific language (ou DSL). Forth est un langage à pile basé dans notre cas sur des entiers de type i32. Nous voulons définir une macro forth! qui puisse exécuter des commandes séparées par des ;, chaque commande ayant un effet sur la pile. La macro renvoie l'état de la pile sous la forme d'un vecteur, le sommet de la pile étant le dernier élément du vecteur.

Par exemple, nous voulons pouvoir écrire :

fn main() {
  println!("final stack = {:?}", forth!(10; 20; add; dup; 6; mul));
}

et que cela affiche

$ cargo run
[30, 180]

Les commandes, qui ont été exécutées dans l'ordre, sont (avec l'état de la pile) :

  • 10 ajoute l'entier 10 au sommet de la pile (vec![10])
  • 20 ajoute l'entier 20 au sommet de la pile (vec![10, 20])
  • add ajoute les deux éléments au sommet de la pile et les remplace par le résultat (vec![30])
  • dup duplique le sommet de la pile (vec![30, 30])
  • 6 ajoute l'entier 6 au sommet de la pile (vec![30, 30, 6])
  • mul multiplie les deux élément au sommet de la pile et les remplace par le résultat (vec![30, 180])

Les autres commandes courantes sont :

  • drop efface l'élément au sommet de la pile
  • swap échange les deux premiers éléments au sommet de la pile
  • sub soustrait le deuxième élément depuis le sommet à l'élément au sommet de la pile
  • div divise le sommet de la pile par le deuxième élément depuis le sommet
  1. Implémentez la macro forth et testez la.

N'oubliez pas qu'une macro peut s'appeler récursivement. Il est possible d'utiliser en interne une syntaxe spéciale pour passer en paramètre la pile sur laquelle on travaille lors d'appels récursifs de la macro.

Génération de code

Les macros peuvent être utilisées pour générer du code. On veut ici définir une arithmétique un peu spéciale appelée "zéro arithmétique" : si le résultat d'une opération ne peut pas être représenté sur le type voulu, cela ne provoque pas une erreur mais renvoie la valeur 0.

On définit un trait Zero_Arith qui rajoute des opérations sur les types qui implémentent ce trait :

trait ZeroArith {
    fn zero_add(self, other: Self) -> Self;
    fn zero_sub(self, other: Self) -> Self;
    fn zero_mul(self, other: Self) -> Self;
    fn zero_div(self, other: Self) -> Self;
}

On souhaite implémenter ce type pour tous les types entiers (isize, i8, i16, i32, i64, i128, usize, u8, u16, u32, u64 et u128) tout en minimisant les répétitions de code.

  1. Après avoir défini ce trait, implémentez une macro impl_zero_arith! qui prend un type en paramètre et implémente les fonctions demandées.

On s'attend ici à ce que vous utilisiez une deuxième macro appelé par la première pour pouvoir implémenter, sur chaque type, les différentes fonctions en minimisant les répétitions.

  1. Testez votre code. Notamment 200u8.zero_add(200u8) doit retourner 0u8, alors que 100u8.zero_add(50u8) retournera 150u8.

Macros procédurales

Alors que les macros simples utilisaient de simples expansions, les macros-procédurales vont nous permettra de manipuler le flux d'entrée de manière programmatique.

Les macros procédurales doivent être définies dans un autre projet, que nous appellerons macros et que nous placerons au même niveau que le projet pwdchk.

  1. Créez un nouveau projet macros de type lib au même niveau que pwdchk :
$ cargo new --lib macros
  1. Indiquez dans le Cargo.toml du projet macros qu'il est de type proc_macro :
[lib]
proc_macro = true
  1. Ajoutez les crates suivants comme dépendances du projet macros :
  • proc-macro2
  • quote
  • syn avec la feature full
  1. Dans le projet pwdchk, ajoutez une dépendance sur le projet macros :
$ cargo add ../macros

Expansion de constantes en français

Nous souhaitons créer une macro procédurale french qui remplacera un nombre litéral (une constante) par sa représentation textuelle en français lors de la compilation. Par exemple, le code suivant

  println!("1000 + 230 = {}", french!(1230));

affichera

1000 + 230 = mille-deux-cent-trente (1230)

Nous nous aiderons pour cela de l'excellent crate french-numbers.

  1. Ajoutez le crate french-numbers aux dépendances du projet macros. Notons que nous ne l'ajouterons pas aux dépendances du projet pwdchk car tout se passera lors de la compilation.

  2. Créez dans src/lib.rs du projet macros une fonction french avec l'attribut #[proc_macro] avec la signature correspondant à une macro procédurale :

extern crate proc_macro;

#[proc_macro]
pub fn french(item: TokenStream) -> TokenStream {
  item
}

Étant donné que proc_macro est fourni par le compilateur et pas ajouté explicitement dans nos dépendances, il faut l'importer avec extern crate proc_macro.

Pour l'instant, notre macro ne fait rien. Nous pouvons le vérifier en important macros::french dans notre programme pwdchk et en recopiant le println!() ci-dessus dans notre programme principal.

Il faut maintenant remplir notre fonction french. En vous aidant des exemples du cours, effectuez les étapes suivantes :

  1. Parsez le flux d'entrée sous la forme d'un LitInt qui représente un entier litéral grâce à la macro idione qui vient du crate syn.
  2. Parsez cet entier litéral, et en cas de succès, construisez la chaîne de caractères contenant sa représentation en français suivi de sa valeur entre parenthèses. Vous pouvez ensuite construire un proc_macro2::TokenStream en utilisant la macro quote du crate éponyme et le convertir en proc_macro::TokenStream avec into().
  3. En cas d'erreur lors de l'étape précédente, insérez avec la macro quote_spanned du crate quote une invocation de la macro compile_error expliquant que la macro french ne fonctionne qu'avec un entier litéral.

Bravo, vous avez créé votre première macro procédurale. Vérifiez qu'elle fonctionne comme attendu.

Ajout automatique de traces

On souhaite implémenter une macro log_function qui a l'effet suivant :

log_function! {
   #[must_use]
   fn mul(a: u32, b: u32) -> u32 {
      a * b
   }
}

fn main() {
  let x = mul(10, 20);
  println!("x = {x}");
}

affichera quelque chose comme

$ cargo run
Entering function mul
Leaving function mul after 64ns
x = 200

L'attribut #[must_use] n'a aucun intérêt ici si ce n'est de montrer qu'on souhaite que le paramètre de la macro soit une fonction quelconque et conserve toutes ses propriétés lors de l'expansion.

  1. À l'aide des mêmes outils que précédemment, implémentez macros::log_function sous la forme d'une macro procédurale.

On cherchera dans le crate syn le type utilisé pour représenter une fonction complète. On pourra également s'intéresser à la macro syn::parse_quote qui fonctionne comme la macro quote::quote mais construit un type dépendant de son contexte plutôt qu'un proc_macro2::TokenStream comme le fait quote::quote. Elle est très utile pour construire des constructions intermédiaires, comme celle représentant le corps d'une fonction.

On prendra un soin particulier à ne pas dépendre des entités présentes dans le contexte. Notamment, si on souhaite utiliser un std::time::Instant, on le qualifiera de ::std::time::Instant pour indiquer qu'on souhaite partir à la racine de la hiérarchie des modules même si un module local appelé std existe.

S'il vous reste du temps

Accès au réseau lors de la compilation

Vous pouvez implémenter une macro procédurale wikipedia_function qui prend deux paramètres, un identifiant de fonction et une chaîne de caractères litérale, comme dans wikipedia_function!(wikipedia_rust => "Rust (langage)"). Cette macro créera une nouvelle fonction à partir de l'identifiant donné (ici wikipedia_rust) retournant une chaîne statique qui contient le contenu de la page Wikipedia correspondant au sujet indiqué (ici "Rust (langage)").

Le parsing de l'argument de la macro se fait facilement en écrivant un parseur dédié qui utilisera le crate syn. En effet, on peut définir une structure contenant deux champs de type String (le nom de la fonction et le nom de la page Wikipedia) et implémenter syn::parse::Parse sur cette structure qui cherchera un identifiant, un token de type => puis une chaîne de caractères.

Mini-Forth again

  1. Réécrivez la macro forth présentée ici en utilisant une macro procédurale.

Cela vous permet de manipuler directement le proc_macro::TokenStream, idéalement après l'avoir converti en proc_macro2::TokenStream plus configurable. Cela vous permet d'accepter du code Rust qui serait incorrect syntaxiquement en Rust (par exemple 2 3 4 + *). La seule contrainte est que les délimiteurs doivent être balancés correctement.

Macros procédurales

Nous allons construire lors de ces travaux pratiques quelques macros procédurales. Nous aurons besoin d'en écrire de nouvelles lors de la partie consacrée à la programmation asynchrone.

  1. Créez un crate macros qui indique dans son Cargo.toml être la source de macros procédurales :
[lib]
proc_macro = true
  1. Ajoutez les dépendances que nous utiliserons pour manipuler les tokens et l'arbre de syntaxe abstrait et pour signaler les erreurs :
  • proc-macro2 pour la manipulation des tokens
  • quote pour la génération de code avec template
  • syn avec les features full, visit-mut et parsing
  • proc-macro-error pour émettre de meilleurs messages d'erreur
  • trybuild pour pouvoir tester les erreurs renvoyées par nos macros

Tout au long de cette partie, vous êtes invités à utiliser des sous-modules pour y stocker vos fonctions utilitaires. Seules les définitions de macro procédurales elles-même doivent se trouver au niveau supérieur du crate.

Enchaîner les calculs

Le langage Elixir permet d'enchaîner les calculs grâce à son opérateur |> qui insère son argument de gauche comme premier élément de l'appel de fonction de son argument de droite :

iex> "Elixir" |> String.graphemes() |> Enum.frequencies()
%{"E" => 1, "i" => 2, "l" => 1, "r" => 1, "x" => 1}

Nous voudrions pouvoir faire la même chose en Rust. Comme nous souhaitons utiliser un visiteur, nous allons prendre en entrée un arbre valide en Rust. Nous allons donc sacrifier le caractère | (pipe, qui représente le « ou bit-à-bit » à cet effet).

#[pipe]
fn pipe_example() {
  let f = "Rust" | str::chars | Vec::from_iter;
  assert_eq!(f, vec!['R', 'u', 's', 't']);
}

Ce code est équivalent à :

fn pipe_example() {
  let f = Vec::from_iter(str::chars("Rust"));
  assert_eq!(f, vec!['R', 'u', 's', 't']);
}

Notre macro #[pipe] et son opérateur | doivent également supporter les appels de fonctions à plusieurs arguments. L'expression a | f(b, c) est équivalente à f(a, b, c).

Implémentation

  1. À l'aide d'un visiteur syn::visit_mut::VisitMut, interceptez l'analyse des expressions et remplacez une expression binaire utilisant l'opérateur | et suivie d'un appel de fonction ou d'un chemin par un appel de fonction qui utilise l'argument de gauche comme premier argument de l'appel. N'oubliez pas de récurser ensuite pour visiter le nouveau nœud (ou les sous-nœuds si aucune transformation n'a été faite).

Demandez-vous quel type de nœud vous souhaitez visiter dans le but de le modifier. Si par exemple vous modifiez un nœud de type ExprBinary qui représente une expression binaire, vous ne pourrez pas le remplacer par un nœud qui ne soit pas une expression binaire. Si vous modifiez un nœud de type Expr qui est moins spécifique cela vous permet de remplacer une expression par une autre.

Également, n'oubliez pas que vous pouvez utiliser syn::parse_quote!() pour générer des nœuds d'un type quelconque à partir d'un template.

  1. Créez une macro procédurale de type attribut pipe qui utilise ce visiteur pour transfomer n'importe quel item en utilisant le visiteur.

  2. Ajoutez des tests vérifiant le comportement correct de cette macro, qui doit pouvoir s'appliquer aussi bien à une fonction qu'à un module ou à une implémentation.

Traduire les chaînes

Nous souhaitons écrire une macro #[translate] qui traduira d'anglais en français les chaînes de caractères contenant des nombres littéraux. Par exemple, le code suivant :

#[translate]
fn main() {
  let res = "forty two";
  println!("12 + 30 = {}", res);
}

affichera

12 + 30 = quarante-deux

Seules les chaînes entières correspondant à des nombres situés dans une plage arbitraire (par exemple 0..=100) seront traduits.

Nous nous aiderons des deux crates suivants pour implémenter cette fonctionnalité :

  1. Ajoutez ces crates comme dépendances au crate macros.

Préchargement des chaînes

Le crate english_numbers ne permet pas de reconnaître un nombre en anglais et d'en récupérer la valeur numérique. Nous allons donc construire un dictionnaire nous permettant de stocker une fois pour toutes la représentation sous forme de chaînes de caractères et d'y associer le nombre associé.

  1. Créez une structure Translate qui contient un dictionnaire associant une chaîne de caractères à un i64, le type utilisé par le crate english_numbers.

  2. Créez une fonction associée new() qui renvoie un objet Translate dont le dictionnaire a été préalablement rempli. Nous activerons uniquement l'option de formattage spaces et laisserons les autres désactivées.

Choix de la technique de remplacement des chaînes

Nous pourrions opter pour un visiteur mutable qui réécrit les nœuds de type LitStr qui correspondent à un nombre en anglais pour y substituer le terme en français. Cette technique qui semble fonctionner au premier abord échouera pourtant sur des tests simples :

#[test]
#[translate]
fn test_translate() {
  assert_eq!("trois", "three");
}

En effet, le visiteur de l'arbre visitera le nœud de type Macro lorsqu'il analysera cette fonction et rencontrera assert_eq!. Le visiteur fera bien une visite aux champs path et delimeter, mais il omettra d'aller visiter les tokens (disponibles sous la forme d'un proc_macro2::TokenStream) qui composent cette macro car ceux-ci ne sont pas forcément du code Rust valide à ce stade.

Il nous faudrait donc intercepter également la visite des nœuds de type Macro afin d'aller substituer les tokens litéraux qui nous intéressent. Si nous devons écrire ce code, étant donné que notre macro procédurale travaille déjà avec des TokenStream, pourquoi ne pas directement implémenter cette solution ? Nous n'avons pas besoin d'un visiteur.

Transformation du flux de tokens

  1. Écrivez une méthode qui substitue les tokens qui correspondant à une chaîne littérale correspondant à un nombre anglais enregistré dans notre dictionnaire par un le nombre français correspondant. Il faudra également prendre soin de rappeler récursivement cette méthode lorsque l'on trouve un groupe délimité de tokens.
impl Translate {

  fn substitute_tokens(stream: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
    todo!()
  }

}

On pourra noter que la représentation littérale à laquelle on a accès est celle qui se trouve dans le code source, c'est-à-dire précédée et suivie des guillemets doubles (on pourra ignorer le cas des chaînes de caractères utilisant d'autres délimiteurs comme r#""#). Plutôt que d'ôter ces guillemets, il peut être plus facile de les ajouter dans le dictionnaire pour pouvoir effectuer une comparaison directe.

  1. Écrivez une macro procédurale #[translate] qui construit un objet Translate et l'utilise pour transformer le TokenStream. On rappelle que les conversions avec From et Into sont implémentées entre proc_macro::TokenStream (à l'interface de notre macro) et proc_macro2::TokenStream (utilisé à l'intérieur de notre macro).

  2. Écrivez des tests pour votre macro. Il pourra être utile de définir avec macro_rules! une macro str!(a, b) qui construit dynamiquement la chaîne constituée de a puis de b en permettant de ne pas faire apparaître de chaîne littérale ab :

// Check that out-of-range (1..=100) values are not translated
assert_eq!(str!("one h", "undred"), "one hundred");

Détermination des bornes positives ou nulles

On souhaite pouvoir indiquer en attribut facultatif les bornes à utiliser pour les nombres à traduire. Les notations suivantes devront être acceptées :

#[translate] fn f() { … }         // Bornes par défaut (0..=100)
#[translate(0..10)] fn f() { … }
#[translate(0..=10)] fn f() { … }

Nous voulons par contre rejeter avec des messages d'erreur clairs les constructions incorrectes :

error: unexpected end of input, expected `..=` or `..`
 --> tests/ui/translate.rs:3:1
  |
3 | #[translate(10)]
  | ^^^^^^^^^^^^^^^^
  |
  = note: this error originates in the attribute macro `translate` (in Nightly builds, run with -Z macro-backtrace for more info)

error: expected integer literal
 --> tests/ui/translate.rs:6:13
  |
6 | #[translate(..10)]
  |             ^^

error: unexpected end of input, expected integer literal
 --> tests/ui/translate.rs:9:1
  |
9 | #[translate(10..)]
  | ^^^^^^^^^^^^^^^^^^
  |
  = note: this error originates in the attribute macro `translate` (in Nightly builds, run with -Z macro-backtrace for more info)

error: expected integer literal
  --> tests/ui/translate.rs:12:13
   |
12 | #[translate(x)]
   |             ^

Pour cela, nous allons construire une structure sur lesquelles nous allons implémenter syn::parse::Parse :

struct Bounds { low: i64, high: i64 }
  1. Implémentez le trait Parse sur Bounds. Cela consiste à lire un entier de type LitInt (syn gère les éventuels moins unaires), à rechercher un symbole parmi ..= et .., à lire la borne haute et à construire l'objet Bounds. L'utilisation de Lookahead1 peut vous faciliter la tâche.

  2. Ajoutez des tests spécifiques pour vérifier que vous pouvez bien lire les différentes formes d'intervalles. Pour ne pas exporter les types privés, vous pouvez ajouter les tests dans un sous-module qui n'existe que lorsqu'on est en configuration de tests. Souvenez vous qu'on peut parser une chaîne de caractères avec syn::parse_str::<T>(s) permet de parser une chaîne de caractère avec le parseur T.

  3. Modifiez la macro translate pour qu'elle lise les bornes depuis son attribut s'il n'est pas vide et initialisez l'objet Translate en conséquence.

  4. Ajoutez des tests, le test ci-dessous doit passer notamment.

#[test]
#[translate(-10..=10)]
fn test_negative_bounds() {
  assert_eq!("moins dix", "negative ten");
  assert_eq!("dix", "ten");
  assert_eq!(str!("neg", "ative eleven"), "negative eleven");
  assert_eq!(str!("ele", "ven"), "eleven");
}

Conclusion

Nous avons vu ici que pour implémenter une macro, plusieurs méthodes peuvent être combinées. Ici nous n'avons pas utilisé de visiteur, mais avons écrit un parseur maison pour les bornes et travaillé directement avec le flux de tokens pour le cœur de l'entité modifiée.

Accès protégé aux champs

On souhaite, pour des buts pédagogiques plus qu'utilitaires, implémenter une macro de type derive Opaque. Celle-ci permet de définir des accesseurs sécurisés pour les champs concernés d'une structure :

#[derive(Opaque)]
struct SecureSettings {
  #[key] secret_key: u64,
  regular_field: String,
  #[opaque] protected_field: String,
  #[opaque] other_protected_field: u32,
}

Notre macro va ajouter automatiquement un accesseur aux champs marqués #[opaque]. Cet accesseur prendra un paramètre key de type référence sur le type du champ marqué avec l'attribut #[key] et renverra une Option contenant le champ demandé uniquement si la clé passée est correcte. Le code généré ressemblera au suivant :

impl SecureSettings {
  fn get_protected_field(&self, key: &u64) -> Option<&String> {
    (key == &self.key).then(|| &self.protected_field)
  }
  fn get_other_protected_field(&self, key: &u64) -> Option<&u32> {
    (key == &self.key).then(|| &self.other_protected_field)
  }
}

Implémentation

  1. Écrivez dans le crate macros le squelette de la macro de type derive pour Opaque. Cette macro prendra en attributs supplémentaires key et opaque qui serviront à qualiier des champs. Pour l'instant, ne renvoyez rien d'utile.

  2. Vérifiez que l'argument passé à la macro est bien une structure contenant des champs nommés et indiquez un message d'erreur approprié si ce n'est pas le cas.

  3. Identifiez le champ marqué avec #[key] qui doit être unique ainsi que les champs marqués avec #[opaque]. Le champ contenant la clé ne peut pas être également #[opaque].

  4. Après avoir mis dans des vecteurs le nom de chaque champ opaque, son type et l'identifiant à utiliser pour son accesseur, générez le code. Vous aurez également besoin d'avoir dans des variables accessibles lors de l'expansion le nom et le type du champ contenant la clé.

  5. Testez, sans oublier les tests avec trybuild pour vérifier la précision des messages d'erreur.