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-commandescargo add
etcargo rm
pour ajouter ou retirer des dépendances d'un projet Rustcargo-criterion
: fournit la sous-commandecargo criterion
utilisée lors des benchmarkscargo-fuzz
: fournit la sous-commandecargo 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.
- Nous allons commencer par demander à
cargo
la création d'un nouveau projetpwdchk
de typebin
. 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 :
- Créez un nouveau module
account
dans le projetpwdchk
. Le corps du module devra se trouver soit danssrc/account.rs
, soit danssrc/account/mod.rs
. - Référencez le module
account
depuissrc/main.rs
pour qu'il soit compilé lors d'uncargo build
. - Créez une structure
Account
contenant deux champs de typeString
:login
etpassword
. - Ajoutez dans l'implémentation de
Account
une méthode de classenew()
qui reçoit le login et le password en paramètres de type&str
et renvoie un nouvelAccount
reprenant ces informations.
struct Account { /* Ajouter les champs ici */ }
impl Account {
fn new(login: &str, password: &str) -> Self {
todo!() // À implémenter
}
}
- Créez une variable de type
Account
depuismain()
en utilisantAccount::new()
. À quel endroit faut-il augmenter la visibilité en ajoutantpub
pour pouvoir créer une variable de typeAccount
et appeler la méthodenew()
? - 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.
- Écrire une méthode de classe
from_string
qui prend une chaîne "login:password" en paramètre et qui renvoie unAccount
:
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
.
- 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é.
- Créez ce type
NoColon
dans le moduleaccount
.
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
.
- Implémentez
FromStr
pourAccount
et renvoyez une erreurNoColon
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
.
-
Faîtes dériver
Debug
automatiquement pour le typeNoColon
et vérifiez que le programme fonctionne comme attendu. -
Vous pouvez supprimer la méthode
Account::from_string()
que nous n'utiliserons plus. -
Changez le type de retour de
main()
pour qu'il soit maintenantResult<(), NoColon>
. Utilisez?
pour retourner prématurément en cas d'erreur. Cela se passera bien carNoColon
implémenteDebug
, ce qui est obligatoire si on veut l'utiliser dans le type de retour demain()
pour pouvoir afficher l'erreur qui a provoqué la fin demain()
.
fn main() -> Result<(), NoColon> {
println!("{:?}", Account::from_str("johndoe")?);
Ok(())
}
Gestion de la ligne de commande
- En utilisant l'itérateur
std::env::args()
et la méthodecollect()
, construisez dansmain()
un vecteur (Vec
) deAccount
à partir des arguments donnés sur la ligne de commande et affichez ce vecteur (si les éléments implémententDebug
, le vecteur implémente égalementDebug
). On n'oubliera pas qu'on peut passer d'une variableString
à sa représentation&str
en utilisant la méthodeas_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.
- Si vous ne l'avez pas fait précédemment, utilisez le fait que
collect()
appliqué à un itérateur sur des valeurs de typeResult<T, E>
peut produire unResult<Collection<T>, E>
oùCollection
est tout type de collection commeVec
. 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 quittermain()
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!()
}
- Implémentez la fonction
account::group()
. On regardera avec intérêt la méthodeHashMap::entry()
et les méthodesor_insert()
ouor_insert_with()
applicables sur ce typeEntry
. - 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",
],
}
- En utilisant la méthode
retain()
deHashMap
, 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",
],
}
- 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.
-
Ajoutez le crate
clap
(version 3.x) à votre projet grâce àcargo add clap@3 --features derive
. Cette sous-commande decargo
, venant du cratecargo-edit
que vous avez installé précedemment, ajoute la dernière version du crate à votre projet avec les optionsderive
de ce crate qui permet d'utiliserParser
. -
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
etdescription
deCargo.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 structureGroupArgs
. - 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 utilisonsVec<Account>
. - Étant donné que nous avons implémenté le trait
FromStr
pourAccount
, 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!()
}
}
}
- 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
suraccount::NoColon
(qui est déjà implémenté pourstd::io::Error
) et utiliser le type dynamiqueBox<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
- Créez un nouveau module
error
dans le projet. - 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,
}
-
Supprimez la définition du type vide
account::NoColon
, et remplacez son utilisation parerror::Error
dans les signatures de fonctions et parerror::Error::NoColon
pour signaler l'occurrence de l'erreur. -
Implémentez le trait
From<std::io::Error>
pourerror::Error
. Cela permettra à l'opérateur?
de convertir grâce à l'appel implicite àinto()
une erreur de typestd::io::Error
enerror::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
- É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 typeU
est requis et queT
implémenteInto<U>
ou queU
implémenteFrom<T>
U::from(t)
siU
implémenteFrom<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)
.
- Ajoutez une option
--file=FILE
(avec la version courte-f FILE
) à la sous-commandegroup
de votre programme pour charger la liste des comptes depuis le fichierFILE
. 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.
-
Indiquez que les comptes données sur la ligne de commande ne sont plus obligatoires en modifiant l'attribut
#[clap]
sur ce champ. -
Indiquez qu'un des deux paramètres
file
ouaccount
sont obligatoires en ajoutant l'attribut idoine sur la structureAppArgs
:
#[clap(group(
ArgGroup::new("input")
.required(true)
.args(&["account", "file"]),
))]
struct AppArgs {
…
}
- 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.
- Changez la définition de
group()
pour correspondre à celle indiquée ci-dessus ainsi que son contenu. - 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.
- Ajoutez le crate
sha1
à votreCargo.toml
. Vous pouvez utilisercargo add sha1
, qui ajoutera la ligne suivante aux dépendances dansCargo.toml
:
[dependencies]
sha1 = "1.0.0"
-
Créez un nouveau module
hibp
(pour Have I been pwned?) dans votre projet. -
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.
- Ajoutez
rayon
comme dépendance à votreCargo.toml
. - Dans votre module
hibp
, importez le prélude derayon
qui fournira toutes les fonctions et extensions utiles,rayon::prelude::*
. - É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 fonctionhibp::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.
-
Écrivez une fonction publique
hibp::all_sha1_timed()
avec la même signature quehibp::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 typesstd::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éthodeas_micros()
sur une valeur de typeDuration
vous obtiendrez sa durée en microsecondes. -
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, puishibp::all_sha1_timed()
sera invoquée. On verra donc le temps de calcul des SHA-1. -
Essayez de remplacer
par_iter()
pariter()
et comparez les temps de traitement.
Regroupement des SHA-1 de même préfixe
- 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.
- Ajoutez le crate
reqwest
avec la featureblocking
à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.
- Enrichissez le type
error::Error
de deux alternativesReqwestError
etParseIntError
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.
- 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.
- 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 formesuffixe: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.
- É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.
- Appelez la fonction
hibp::check_accounts()
depuis le programme principal au sein de la sous-commandehibp
.
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.
- Ajoutez le crate
tokio
aux dépendances de votre projet, avec la featurefull
pour bénéficier de l'exécutif multi-cœurs. - Ajoutez-y également le crate
futures
qui fournit des extensions utiles sur les types implémentant les traitsFuture
etStream
. - Lancez automatiquement l'exécutif de
tokio
en déclarant la fonctionmain()
asynchrone et en lui associant l'attributtokio::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.
- Créez le module
scanner
. Étant donné qu'il aura des sous-modules, créez le dans le fichiersrc/scanner/mod.rs
plutôt que danssrc/scanner.rs
afin de regrouper le module et ses sous-modules dans le même dossier. - Créez un module
scanner::net
dans le fichiersrc/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.
- 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!()
}
- Après avoir mis les droits nécessaires pour permettre à
main()
d'atteindre cette fonction, rajoutez une sous-commandeping
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 leTcpStream
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
.
- Modifiez la signature de la fonction
scanner::net::tcp_ping()
et son appel dansmain()
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.
- 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érationerror::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
-
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. -
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 utiliserbuffer_unordered()
pour exécuter lesFuture
en provenance du stream en limitant le nombre deFuture
non terminées qui s'exécutent en parallèle. - À partir d'un itérateur sur des
Future
, on peut construire unfutures::stream::FuturesUnordered
qui implémenteStream
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.
- 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 appelletcp_ping_many()
sur cette liste.
pub async fn tcp_mping<'a>(targets: &[&'a str], ports: &[u16])
-> Vec<(&'a str, u16, Result<bool, Error>)>
{
todo!()
}
- Modifiez le programme principal pour qu'il appelle
tcp_mping()
au lieu detcp_ping()
. Changez la visibilité detcp_ping()
qui n'a plus de raison d'êtrepub
.
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.x
où x
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.
- Ajoutez le crate
ipnet
à vos dépendances dansCargo.toml
. - É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.
- 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.
- 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.
- Ajoutez un type
scanner::IdentificationResult
contenant trois alternatives :WelcomeLine(String)
,NoWelcomeLine
etConnectionRefused
. - Écrivez une fonction asynchrone
scanner::net::welcome_line()
qui prend untokio::net::TcpStream
en paramètre et qui renvoie unIdentificationResult
contenant soitWelcomeLine(…)
si le serveur a envoyé une ligne de bienvenue dans la première seconde, ouNoWelcomeLine
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
.
-
Modifiez la fonction
tcp_ping()
pour qu'elle appellewelcome_line()
si la connexion au serveur distant réussi. Le résultat devra être modifié pour renvoyer unIdentificationResult
à la place dubool
précédemment utilisé. -
Modifiez vos fonctions
tcp_ping()
,tcp_ping_many()
ettcp_mping()
pour propager de la même manière ce résultat. -
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émenteExactSizeIterator
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()
etexecute_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 lafeature
qui correspond à votre microcontrôleur pour le crateembassy-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
- Écrivez avec
macro_rules!
une macrocartesian
qui prend en paramètre deux expressions implémentant respectivementIntoIterator<T>
etIntoIterator<U>
, avecT: Clone
etU: 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")]
- É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!()
etline!()
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
.
- É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 pileswap
échange les deux premiers éléments au sommet de la pilesub
soustrait le deuxième élément depuis le sommet à l'élément au sommet de la pilediv
divise le sommet de la pile par le deuxième élément depuis le sommet
- 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.
- 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.
- Testez votre code. Notamment
200u8.zero_add(200u8)
doit retourner0u8
, alors que100u8.zero_add(50u8)
retournera150u8
.
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
.
- Créez un nouveau projet
macros
de typelib
au même niveau quepwdchk
:
$ cargo new --lib macros
- Indiquez dans le
Cargo.toml
du projetmacros
qu'il est de typeproc_macro
:
[lib]
proc_macro = true
- Ajoutez les crates suivants comme dépendances du projet
macros
:
proc-macro2
quote
syn
avec la featurefull
- Dans le projet
pwdchk
, ajoutez une dépendance sur le projetmacros
:
$ 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
.
-
Ajoutez le crate
french-numbers
aux dépendances du projetmacros
. Notons que nous ne l'ajouterons pas aux dépendances du projetpwdchk
car tout se passera lors de la compilation. -
Créez dans
src/lib.rs
du projetmacros
une fonctionfrench
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 :
- 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 cratesyn
. - 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 macroquote
du crate éponyme et le convertir enproc_macro::TokenStream
avecinto()
. - En cas d'erreur lors de l'étape précédente, insérez avec la macro
quote_spanned
du cratequote
une invocation de la macrocompile_error
expliquant que la macrofrench
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.
- À 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
- 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.
- Créez un crate
macros
qui indique dans sonCargo.toml
être la source de macros procédurales :
[lib]
proc_macro = true
- 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 tokensquote
pour la génération de code avec templatesyn
avec les featuresfull
,visit-mut
etparsing
proc-macro-error
pour émettre de meilleurs messages d'erreurtrybuild
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
- À 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.
-
Créez une macro procédurale de type attribut
pipe
qui utilise ce visiteur pour transfomer n'importe quel item en utilisant le visiteur. -
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é :
english_numbers
pour les nombres en anglaisfrench_numbers
pour les nombres en français
- 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é.
-
Créez une structure
Translate
qui contient un dictionnaire associant une chaîne de caractères à uni64
, le type utilisé par le crateenglish_numbers
. -
Créez une fonction associée
new()
qui renvoie un objetTranslate
dont le dictionnaire a été préalablement rempli. Nous activerons uniquement l'option de formattagespaces
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
- É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.
-
Écrivez une macro procédurale
#[translate]
qui construit un objetTranslate
et l'utilise pour transformer leTokenStream
. On rappelle que les conversions avecFrom
etInto
sont implémentées entreproc_macro::TokenStream
(à l'interface de notre macro) etproc_macro2::TokenStream
(utilisé à l'intérieur de notre macro). -
Écrivez des tests pour votre macro. Il pourra être utile de définir avec
macro_rules!
une macrostr!(a, b)
qui construit dynamiquement la chaîne constituée dea
puis deb
en permettant de ne pas faire apparaître de chaîne littéraleab
:
// 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 }
-
Implémentez le trait
Parse
surBounds
. Cela consiste à lire un entier de typeLitInt
(syn
gère les éventuels moins unaires), à rechercher un symbole parmi..=
et..
, à lire la borne haute et à construire l'objetBounds
. L'utilisation de Lookahead1 peut vous faciliter la tâche. -
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 parseurT
. -
Modifiez la macro
translate
pour qu'elle lise les bornes depuis son attribut s'il n'est pas vide et initialisez l'objetTranslate
en conséquence. -
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
-
Écrivez dans le crate
macros
le squelette de la macro de type derive pourOpaque
. Cette macro prendra en attributs supplémentaireskey
etopaque
qui serviront à qualiier des champs. Pour l'instant, ne renvoyez rien d'utile. -
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.
-
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]
. -
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é.
-
Testez, sans oublier les tests avec
trybuild
pour vérifier la précision des messages d'erreur.