def envoyer_fichier(
chemin: Path | str,
device: dict,
*,
poster: Any = None,
progression: Any = None,
attente: Any = None,
dormir: Any = time.sleep,
maintenant: Any = time.monotonic,
) -> Envoi:
"""Pousser un fichier vers un appareil déjà appairé.
``device`` est la ligne du registre : il porte l'adresse et le niveau de
confiance. La vérification que l'on a le DROIT de le joindre reste celle
du transport — un seul endroit décide de ce qui sort de la machine.
"""
from diapason.mesh.coffre import cle_de_session, nouvelle_demi_cle
from diapason.mesh.identity import device_identity, owner_id
from diapason.mesh.signed import sign_payload
from diapason.mesh.transfert import decrire_fichier, lire_morceaux
from diapason.mesh.transport import assert_may_reach_device
chemin = Path(chemin).expanduser()
if not chemin.is_file():
raise EnvoiRefuse(f"{chemin} n'est pas un fichier.")
adresse = str(device.get("address") or "").strip()
if not adresse:
raise EnvoiRefuse("L'adresse de cet appareil est inconnue.")
# Le même garde que pour les commandes : ce qui sort de la machine passe
# par une seule porte, jamais par une seconde écrite pour l'occasion.
assert_may_reach_device(device, adresse)
manifeste = decrire_fichier(chemin)
demi = nouvelle_demi_cle()
offre = sign_payload(
{
"version": 1,
"ownerId": owner_id(),
"deviceId": device_identity().device_id,
"sentAtMs": int(time.time() * 1000),
"sessionNonce": secrets.token_urlsafe(12),
"manifest": manifeste.to_dict(),
"ephemeralPublicKey": demi.publique_b64,
},
_champs(),
)
base = adresse.rstrip("/")
envoyer = poster or _poster
reponse = _verifier_reponse(
envoyer(f"{base}/v1/mesh/files/offer", offre, None), device
)
if reponse.get("status") == "ALREADY_PRESENT":
return Envoi(
statut="ALREADY_PRESENT",
message=str(reponse.get("userSafeMessage") or "Déjà présent."),
chemin_distant=str(reponse.get("path") or ""),
)
# Rolling-upgrade compatibility for receivers older than 28 August 2026.
# Current versions answer ACCEPTED immediately to a trusted peer, but
# removing this path would require every device in a fleet to update at
# exactly the same time.
if reponse.get("status") == "PENDING":
request_id = str(reponse.get("requestId") or "")
request_token = str(reponse.get("requestToken") or "")
if not request_id or not request_token:
raise EnvoiRefuse("La demande d'accord du destinataire est incomplète.")
message_attente = str(
reponse.get("userSafeMessage")
or "En attente de l'accord sur l'appareil destinataire."
)
if attente is not None:
try:
attente(message_attente)
except Exception: # noqa: BLE001 - l'affichage n'arrête pas l'envoi
pass
try:
delai_accord = int(reponse.get("expiresInS") or 120)
except (TypeError, ValueError, OverflowError):
delai_accord = 120
# La réponse est signée, mais le récepteur ne décide pas combien de
# temps l'émetteur garde une commande bloquée. Deux minutes est aussi
# le TTL côté réception : plus long ne pourrait que sonder un cadavre.
limite = maintenant() + min(max(delai_accord, 1), 120)
while reponse.get("status") == "PENDING":
restant = limite - maintenant()
if restant <= 0:
return Envoi(
statut="EXPIRED",
message="Aucune réponse n'a été donnée — rien n'a été envoyé.",
)
try:
pause = float(reponse.get("pollAfterMs") or 2000) / 1000
except (TypeError, ValueError, OverflowError):
pause = 2.0
dormir(min(max(pause, 0.25), 5.0, restant))
reponse = _verifier_reponse(
envoyer(
f"{base}/v1/mesh/files/requests/{request_id}/state",
{},
request_token,
),
device,
)
rendu_id = str(reponse.get("requestId") or "")
if rendu_id and rendu_id != request_id:
raise EnvoiRefuse("Le destinataire a répondu à une autre demande.")
statut = str(reponse.get("status") or "")
if statut in {"DENIED", "EXPIRED"}:
return Envoi(
statut=statut,
message=str(
reponse.get("userSafeMessage")
or "Le destinataire n'a pas accepté ce fichier."
),
)
if statut != "ACCEPTED":
raise EnvoiRefuse(
f"Réponse inattendue du destinataire : {statut or 'sans statut'}."
)
session_id = str(reponse.get("sessionId") or "")
jeton = str(reponse.get("uploadToken") or "")
if not session_id or not jeton:
raise EnvoiRefuse("L'appareil n'a pas ouvert de session de transfert.")
cle = cle_de_session(demi, str(reponse.get("ephemeralPublicKey") or ""), session_id)
from diapason.mesh.coffre import sceller
envoyes = 0
for index, bloc in lire_morceaux(chemin):
envoyer(
f"{base}/v1/mesh/files/{session_id}/chunk?index={index}",
sceller(cle, index, bloc),
jeton,
)
envoyes += 1
if progression is not None:
try:
progression(envoyes, manifeste.morceaux)
except Exception: # noqa: BLE001 - l'affichage n'arrête pas l'envoi
pass
fin = _verifier_reponse(
envoyer(f"{base}/v1/mesh/files/{session_id}/finish", {}, jeton), device
)
if str(fin.get("sessionId") or "") != session_id:
raise EnvoiRefuse("Le destinataire a confirmé une autre session.")
# La phrase vient du récepteur : lui seul a vérifié l'empreinte. Depuis le
# 26 août 2026, sa confirmation est signée elle aussi ; sinon un voisin
# pouvait forger COMPLETE après avoir jeté le dernier morceau.
return Envoi(
statut=str(fin.get("status") or "?"),
message=str(fin.get("userSafeMessage") or ""),
chemin_distant=str(fin.get("path") or ""),
octets=int(fin.get("bytes") or 0),
morceaux=envoyes,
)