Skip to content

transfert

transfert

Le transfert de fichiers entre appareils : le cœur, sans réseau.

Spatial Mesh, phase 3 — 25 août 2026. Le maillage était un maillage de COMMANDES : des enveloppes sans état, courtes, qui expirent. Un fichier ne rentre pas là-dedans, et le cahier des charges l'interdit explicitement — ajouter un champ à l'enveloppe signée invaliderait toutes les signatures du client mobile. Le transfert a donc sa propre session, son propre cycle de vie, ses propres routes.

Ce module ne parle à personne : il décrit un transfert, découpe, vérifie, assainit, et recoud. Tout ce qui touche au réseau ou au disque est injecté ou vit ailleurs, pour que la logique se teste sans machine en face.

Trois règles qui viennent de vraies erreurs :

  1. Jamais tout en mémoire. Un fichier d'un gigaoctet lu d'un bloc, c'est un gigaoctet de RAM sur une machine qui fait tourner un modèle de langue.
  2. Le nom reçu est une donnée hostile, pas un chemin. « ../../.ssh/ authorized_keys » est un nom de fichier parfaitement valide côté émetteur.
  3. Rien n'est visible avant d'être entier. Un fichier partiel qui porte déjà son nom final sera ouvert par quelqu'un, un jour, au milieu d'un transfert.

Classes

Manifeste dataclass

Manifeste(
    nom: str,
    taille: int,
    hachage: str,
    type_mime: str = "",
    morceaux: int = 0,
)

Ce qu'on annonce AVANT d'envoyer un octet.

Le récepteur décide sur cette annonce seule : trop gros, nom refusé, déjà présent. Refuser après coup, c'est avoir déjà payé le transfert.

RefusDeTransfert

Bases: ValueError

Le transfert est refusé, et le message dit pourquoi à un humain.

Reception dataclass

Reception(
    manifeste: Manifeste,
    dossier: Path,
    partiel: Path,
    recus: set[int] = set(),
    taille_morceau: int = TAILLE_MORCEAU,
)

Un transfert en cours de réception, repris là où il s'était arrêté.

Les morceaux vont dans un fichier « .partiel » qui ne porte PAS le nom final : rien n'est visible avant d'être entier et vérifié.

Attributes
manquants property
manquants: list[int]

Ce que l'émetteur doit (re)envoyer — la reprise tient là-dedans.

Methods:
ecrire
ecrire(index: int, octets: bytes) -> None

Poser un morceau à sa place. Rejouer le même morceau est inoffensif.

Source code in src/diapason/mesh/transfert.py
def ecrire(self, index: int, octets: bytes) -> None:
    """Poser un morceau à sa place. Rejouer le même morceau est inoffensif."""
    if index < 0 or index >= self.manifeste.morceaux:
        raise RefusDeTransfert(f"Morceau {index} hors du fichier annoncé.")
    attendu = self.taille_morceau
    dernier = index == self.manifeste.morceaux - 1
    if not dernier and len(octets) != attendu:
        raise RefusDeTransfert(
            f"Morceau {index} de taille inattendue ({len(octets)})."
        )
    if dernier:
        reste = self.manifeste.taille - index * attendu
        if len(octets) != reste:
            raise RefusDeTransfert("Dernier morceau de taille inattendue.")
    self.partiel.parent.mkdir(parents=True, exist_ok=True)
    # Ouverture en r+b pour écrire à la position exacte : les morceaux
    # peuvent arriver dans le désordre après une reprise.
    mode = "r+b" if self.partiel.exists() else "wb"
    with open(self.partiel, mode) as f:
        f.seek(index * attendu)
        f.write(octets)
    self.recus.add(index)
finaliser
finaliser() -> Path

Vérifier l'intégrité, puis rendre le fichier visible d'un seul coup.

L'empreinte est recalculée sur ce qui est RÉELLEMENT sur le disque : faire confiance au compte des morceaux reviendrait à croire l'émetteur sur parole.

Source code in src/diapason/mesh/transfert.py
def finaliser(self) -> Path:
    """Vérifier l'intégrité, puis rendre le fichier visible d'un seul coup.

    L'empreinte est recalculée sur ce qui est RÉELLEMENT sur le disque :
    faire confiance au compte des morceaux reviendrait à croire
    l'émetteur sur parole.
    """
    if not self.complet:
        raise RefusDeTransfert(
            f"Transfert incomplet : {len(self.manquants)} morceau(x) manquant(s)."
        )
    taille_reelle = self.partiel.stat().st_size
    if taille_reelle != self.manifeste.taille:
        raise RefusDeTransfert(
            f"Taille finale {taille_reelle}, annoncée {self.manifeste.taille}."
        )
    sha = hashlib.sha256()
    with open(self.partiel, "rb") as f:
        for bloc in iter(lambda: f.read(TAILLE_MORCEAU), b""):
            sha.update(bloc)
    if sha.hexdigest() != self.manifeste.hachage:
        self.partiel.unlink(missing_ok=True)
        raise RefusDeTransfert(
            "Le fichier reçu ne correspond pas à son empreinte — "
            "transfert abandonné."
        )
    cible = nom_libre(self.dossier, assainir_nom(self.manifeste.nom))
    # Jamais exécutable : un fichier reçu du réseau ne s'exécute pas, et
    # ne doit même pas pouvoir le prétendre.
    os.chmod(self.partiel, 0o600)
    os.replace(self.partiel, cible)  # atomique sur le même système
    return cible

Functions:

assainir_nom

assainir_nom(brut: str) -> str

Un nom de fichier sûr, dérivé d'un nom reçu qu'on suppose hostile.

« ../../.ssh/authorized_keys » est un nom parfaitement valide pour l'émetteur : c'est au récepteur de refuser d'y croire.

Source code in src/diapason/mesh/transfert.py
def assainir_nom(brut: str) -> str:
    """Un nom de fichier sûr, dérivé d'un nom reçu qu'on suppose hostile.

    « ../../.ssh/authorized_keys » est un nom parfaitement valide pour
    l'émetteur : c'est au récepteur de refuser d'y croire.
    """
    nom = "".join(
        caractere
        for caractere in unicodedata.normalize("NFC", str(brut or ""))
        # Les contrôles bidi et caractères invisibles ne sont pas des
        # séparateurs, donc la regex historique les laissait traverser. Un
        # `rapport\u202egnp.exe` pouvait alors se présenter visuellement comme
        # une image tandis que le disque conservait un exécutable.
        if unicodedata.category(caractere) != "Cf"
    ).strip()
    # Le séparateur d'abord : on ne garde que le dernier segment, quelle que
    # soit la convention de l'émetteur.
    nom = nom.replace("\\", "/").split("/")[-1]
    nom = _INTERDITS.sub("_", nom)
    # Un nom qui commence par un point se cache ; un nom qui n'est QUE des
    # points remonte l'arborescence.
    nom = _POINTS_EN_TETE.sub("", nom).strip(" .")
    if not nom:
        return "fichier"
    if len(nom) > _NOM_MAX:
        tige, point, extension = nom.rpartition(".")
        if point and 0 < len(extension) <= 10:
            garde = _NOM_MAX - len(extension) - 1
            nom = f"{tige[:garde]}.{extension}"
        else:
            nom = nom[:_NOM_MAX]
    return nom

nom_libre

nom_libre(dossier: Path, nom: str) -> Path

Un chemin qui n'écrase rien : « photo.jpg », puis « photo (2).jpg ».

Écraser un fichier existant serait une perte de données décidée par quelqu'un d'autre que son propriétaire.

Source code in src/diapason/mesh/transfert.py
def nom_libre(dossier: Path, nom: str) -> Path:
    """Un chemin qui n'écrase rien : « photo.jpg », puis « photo (2).jpg ».

    Écraser un fichier existant serait une perte de données décidée par
    quelqu'un d'autre que son propriétaire.
    """
    cible = dossier / nom
    if not cible.exists():
        return cible
    tige, point, extension = nom.rpartition(".")
    if not point:
        tige, extension = nom, ""
    for i in range(2, 1000):
        candidat = f"{tige} ({i}){'.' + extension if extension else ''}"
        cible = dossier / candidat
        if not cible.exists():
            return cible
    raise ValueError("Trop de fichiers portent déjà ce nom.")

decrire_fichier

decrire_fichier(
    chemin: Path, *, taille_morceau: int = TAILLE_MORCEAU
) -> Manifeste

Le manifeste d'un fichier local — lu par blocs, jamais d'un coup.

Source code in src/diapason/mesh/transfert.py
def decrire_fichier(chemin: Path, *, taille_morceau: int = TAILLE_MORCEAU) -> Manifeste:
    """Le manifeste d'un fichier local — lu par blocs, jamais d'un coup."""
    chemin = Path(chemin)
    if not chemin.is_file():
        raise ValueError(f"{chemin} n'est pas un fichier.")
    taille = chemin.stat().st_size
    sha = hashlib.sha256()
    with open(chemin, "rb") as f:
        for bloc in iter(lambda: f.read(taille_morceau), b""):
            sha.update(bloc)
    import mimetypes

    return Manifeste(
        nom=assainir_nom(chemin.name),
        taille=taille,
        hachage=sha.hexdigest(),
        type_mime=mimetypes.guess_type(chemin.name)[0] or "",
        morceaux=nombre_de_morceaux(taille, taille_morceau),
    )

lire_morceaux

lire_morceaux(
    chemin: Path, *, taille_morceau: int = TAILLE_MORCEAU
) -> Iterator[tuple[int, bytes]]

(index, octets) — un fichier ne passe jamais entièrement en mémoire.

Source code in src/diapason/mesh/transfert.py
def lire_morceaux(
    chemin: Path, *, taille_morceau: int = TAILLE_MORCEAU
) -> Iterator[tuple[int, bytes]]:
    """(index, octets) — un fichier ne passe jamais entièrement en mémoire."""
    with open(chemin, "rb") as f:
        index = 0
        while True:
            bloc = f.read(taille_morceau)
            if not bloc:
                return
            yield index, bloc
            index += 1

verifier_le_manifeste

verifier_le_manifeste(
    manifeste: Manifeste,
    *,
    taille_max: int = TAILLE_MAX_DEFAUT,
) -> None

Tout ce qu'on peut refuser AVANT le premier octet.

Source code in src/diapason/mesh/transfert.py
def verifier_le_manifeste(
    manifeste: Manifeste, *, taille_max: int = TAILLE_MAX_DEFAUT
) -> None:
    """Tout ce qu'on peut refuser AVANT le premier octet."""
    if manifeste.taille < 0:
        raise RefusDeTransfert("Taille de fichier absurde.")
    if manifeste.taille > taille_max:
        gio = taille_max / (1024**3)
        raise RefusDeTransfert(f"Ce fichier dépasse la limite de {gio:.1f} Gio.")
    if len(manifeste.hachage) != 64 or not re.fullmatch(
        r"[0-9a-f]{64}", manifeste.hachage
    ):
        raise RefusDeTransfert("Empreinte de fichier illisible.")
    if manifeste.morceaux != nombre_de_morceaux(manifeste.taille):
        raise RefusDeTransfert("Le découpage annoncé ne correspond pas à la taille.")

ouvrir_reception

ouvrir_reception(
    manifeste: Manifeste,
    dossier: Path,
    *,
    session_id: str,
    taille_max: int = TAILLE_MAX_DEFAUT,
    taille_morceau: int = TAILLE_MORCEAU,
) -> Reception

Préparer la réception d'un fichier annoncé — refus avant tout octet.

Source code in src/diapason/mesh/transfert.py
def ouvrir_reception(
    manifeste: Manifeste,
    dossier: Path,
    *,
    session_id: str,
    taille_max: int = TAILLE_MAX_DEFAUT,
    taille_morceau: int = TAILLE_MORCEAU,
) -> Reception:
    """Préparer la réception d'un fichier annoncé — refus avant tout octet."""
    verifier_le_manifeste(manifeste, taille_max=taille_max)
    dossier = Path(dossier)
    dossier.mkdir(mode=0o700, parents=True, exist_ok=True)
    # Le fichier partiel porte l'identifiant de session, pas le nom annoncé :
    # deux transferts du même nom ne se marchent pas dessus, et rien ne
    # ressemble au fichier final avant qu'il ne le soit.
    partiel = dossier / f".{session_id}.partiel"
    return Reception(
        manifeste=manifeste,
        dossier=dossier,
        partiel=partiel,
        taille_morceau=taille_morceau,
    )

deja_present

deja_present(
    manifeste: Manifeste,
    dossier: Path,
    *,
    taille_morceau: int = TAILLE_MORCEAU,
) -> Optional[Path]

Le fichier est-il déjà là, au même contenu ? (§45, déduplication.)

Compare les empreintes, jamais les noms : deux fichiers de même nom peuvent différer, et deux noms différents porter le même contenu.

Source code in src/diapason/mesh/transfert.py
def deja_present(
    manifeste: Manifeste, dossier: Path, *, taille_morceau: int = TAILLE_MORCEAU
) -> Optional[Path]:
    """Le fichier est-il déjà là, au même contenu ? (§45, déduplication.)

    Compare les empreintes, jamais les noms : deux fichiers de même nom
    peuvent différer, et deux noms différents porter le même contenu.
    """
    dossier = Path(dossier)
    if not dossier.is_dir():
        return None
    for candidat in dossier.iterdir():
        if not candidat.is_file() or candidat.name.startswith("."):
            continue
        if candidat.stat().st_size != manifeste.taille:
            continue  # la taille écarte l'essentiel sans rien lire
        sha = hashlib.sha256()
        with open(candidat, "rb") as f:
            for bloc in iter(lambda: f.read(taille_morceau), b""):
                sha.update(bloc)
        if sha.hexdigest() == manifeste.hachage:
            return candidat
    return None