ekodide 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ekodide/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """Ekodide — envia e recebe arquivos pela rede, lacrados e idênticos.
2
+
3
+ O nome é de um papagaio africano (odídẹ): repete com perfeição (o arquivo chega
4
+ cópia EXATA, sha256 idêntico) e VOA (vai de um aparelho a outro pela rede, sem
5
+ cabo). É código burro e determinístico — não tem IA aqui dentro. Algo pode ACIONAR
6
+ o Ekodide (um humano no terminal, um script, um agente), mas quem faz o trabalho é
7
+ este maquinário fixo.
8
+
9
+ Como biblioteca:
10
+ from ekodide import enviar, servir
11
+ enviar(origem, url, segredo) # manda arquivo/pasta
12
+ servir(base, segredo, host="0.0.0.0") # escuta e grava
13
+
14
+ Como comando (depois de instalar):
15
+ ekodide send arquivo --para pc
16
+ ekodide serve
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from .buscador import ErroPuxar, puxar
21
+ from .buscador import listar as listar_remoto
22
+ from .caixa_postal import gravar_recebido, guardar, guardar_pedaco
23
+ from .carteiro import EnvioResultado, enviar
24
+ from .lacre import (
25
+ JANELA_SEGUNDOS,
26
+ TrancaInvalida,
27
+ desempacotar,
28
+ empacotar,
29
+ segredo_do_ambiente,
30
+ )
31
+ from .recebedor import servir
32
+ from .vizinhanca import anunciar, anunciar_em_thread, procurar
33
+ from .frase import gerar as gerar_frase
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "enviar",
39
+ "EnvioResultado",
40
+ "puxar",
41
+ "listar_remoto",
42
+ "ErroPuxar",
43
+ "servir",
44
+ "gravar_recebido",
45
+ "guardar",
46
+ "guardar_pedaco",
47
+ "empacotar",
48
+ "desempacotar",
49
+ "segredo_do_ambiente",
50
+ "TrancaInvalida",
51
+ "JANELA_SEGUNDOS",
52
+ "anunciar",
53
+ "anunciar_em_thread",
54
+ "procurar",
55
+ "gerar_frase",
56
+ ]
ekodide/acervo.py ADDED
@@ -0,0 +1,84 @@
1
+ """O acervo do Ekodide: o lado de LEITURA que o 'puxar' expõe.
2
+
3
+ Espelho do caixa_postal (que GRAVA o que chega): aqui a gente LÊ arquivos de dentro
4
+ de uma pasta COMPARTILHADA — com a mesma cerca, e um cuidado a mais. Um ponto de
5
+ leitura é poder; então só se lê de dentro do que foi explicitamente compartilhado, e:
6
+
7
+ - travessia de caminho ('../') é descartada — nunca escapa da pasta;
8
+ - symlink que aponta pra FORA da pasta é recusado (o alvo real cai fora da cerca) —
9
+ risco que o lado de escrita não corre, mas o de leitura sim;
10
+ - os temporários de recebimento ('.parcial'/'.parcial.meta') NÃO entram na lista
11
+ (são montagem em andamento, não arquivo pra compartilhar).
12
+
13
+ Lógica pura (sem rede, sem variáveis de ambiente): recebe a pasta `base` e um nome
14
+ relativo. Quem expõe isso pela rede (HTTP) é o recebedor (rotas /listar e /buscar).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ # Mesmo tamanho de pedaço do carteiro: arquivo grande é lido/devolvido picado, e cada
21
+ # pedaço cabe no corpo de 32 MB do recebedor mesmo depois do base64 inchar ~33%.
22
+ PEDACO = 16 * 1024 * 1024
23
+
24
+
25
+ def _resolver_dentro(nome: str, base: Path) -> Path:
26
+ """De um `nome` relativo ('Fotos/sub/img.png') devolve o caminho REAL dentro de
27
+ `base`, NUNCA escapando. Componentes perigosos ('', '.', '..', raiz absoluta) são
28
+ descartados; o resultado é resolvido (segue symlink de propósito) e conferido contra
29
+ a base — assim um atalho apontando pra fora é pego. Levanta ValueError se escapar."""
30
+ base = base.resolve()
31
+ rel = Path(nome)
32
+ if rel.is_absolute(): # caminho absoluto vira relativo (tira a raiz '/')
33
+ rel = Path(*rel.parts[1:])
34
+ partes = [p for p in rel.parts if p not in ("", ".", "..")]
35
+ if not partes:
36
+ raise ValueError("nome de arquivo inválido")
37
+ alvo = base.joinpath(*partes).resolve() # resolve() segue symlink: pega fuga por atalho
38
+ if base != alvo and base not in alvo.parents:
39
+ raise ValueError("fora da pasta compartilhada")
40
+ return alvo
41
+
42
+
43
+ def _e_compartilhavel(p: Path) -> bool:
44
+ """Arquivo comum de verdade — fora os temporários de recebimento em montagem."""
45
+ return p.is_file() and not p.name.endswith((".parcial", ".parcial.meta"))
46
+
47
+
48
+ def listar(base: Path) -> list[dict]:
49
+ """O que dá pra puxar: lista de {'nome': caminho-relativo, 'tamanho': bytes},
50
+ ordenada por nome, recursiva (preserva subpastas). Pasta inexistente ou não
51
+ compartilhada (None) -> lista vazia: nada fica exposto sem querer."""
52
+ if base is None:
53
+ return []
54
+ base = Path(base).expanduser()
55
+ if not base.is_dir():
56
+ return []
57
+ base = base.resolve()
58
+ achados = []
59
+ for p in sorted(base.rglob("*")):
60
+ if _e_compartilhavel(p):
61
+ achados.append({"nome": p.relative_to(base).as_posix(), "tamanho": p.stat().st_size})
62
+ return achados
63
+
64
+
65
+ def tamanho_de(nome: str, base: Path) -> int:
66
+ """Tamanho (bytes) de um arquivo compartilhado. Mesma cerca do `ler_pedaco`."""
67
+ alvo = _resolver_dentro(nome, Path(base).expanduser())
68
+ if not _e_compartilhavel(alvo):
69
+ raise ValueError("arquivo não disponível")
70
+ return alvo.stat().st_size
71
+
72
+
73
+ def ler_pedaco(nome: str, base: Path, parte: int, partes: int) -> bytes:
74
+ """Lê o pedaço `parte` (de `partes`) do arquivo, cada um de até PEDACO bytes (o
75
+ último vem menor). Arquivo que cabe num pedaço vai inteiro com parte=0, partes=1.
76
+ Cerca de segurança: nunca lê fora da pasta compartilhada."""
77
+ if partes < 1 or parte < 0 or parte >= partes:
78
+ raise ValueError("índice de pedaço inválido")
79
+ alvo = _resolver_dentro(nome, Path(base).expanduser())
80
+ if not _e_compartilhavel(alvo):
81
+ raise ValueError("arquivo não disponível")
82
+ with alvo.open("rb") as f:
83
+ f.seek(parte * PEDACO)
84
+ return f.read(PEDACO)
ekodide/buscador.py ADDED
@@ -0,0 +1,102 @@
1
+ """O buscador do Ekodide: PUXA um arquivo de outra ponta (o inverso do carteiro).
2
+
3
+ O carteiro EMPURRA (posta na /receber do outro). O buscador PUXA: pergunta o que há
4
+ (/listar) e pede o arquivo (/buscar) da pasta que o outro compartilhou. Cada pedaço
5
+ volta CIFRADO (cofre) e lacrado (HMAC); aqui a gente abre o lacre, decifra, e grava
6
+ local reusando a caixa postal (mesma cerca de escrita, mesma remontagem de pedaços).
7
+
8
+ Não lê variáveis de ambiente: recebe a URL e o segredo prontos, como o carteiro.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import binascii
14
+ import http.client
15
+ from pathlib import Path
16
+
17
+ from cryptography.exceptions import InvalidTag
18
+
19
+ from . import acervo, caixa_postal
20
+ from .carteiro import _Linha # mesma conexão keep-alive do empurrar (reaproveitada)
21
+ from .cofre import decifrar
22
+ from .lacre import TrancaInvalida, desempacotar, empacotar
23
+
24
+
25
+ class ErroPuxar(Exception):
26
+ """Falha ao puxar (origem fora do ar, recusada, ou resposta fora da tranca)."""
27
+
28
+
29
+ def listar(url: str, segredo: str) -> list[dict]:
30
+ """O que dá pra puxar da `url`: lista de {'nome', 'tamanho'}. Levanta ErroPuxar
31
+ se a origem recusar ou a resposta não abrir o lacre."""
32
+ linha = _Linha(url)
33
+ try:
34
+ try:
35
+ status, bruto = linha.postar("/listar", empacotar({}, segredo))
36
+ except (http.client.HTTPException, OSError) as erro:
37
+ raise ErroPuxar(f"não alcancei a origem ({erro})")
38
+ if status != 200:
39
+ raise ErroPuxar(f"origem recusou listar ({status}): {bruto.decode('utf-8', 'replace')}")
40
+ try:
41
+ itens = desempacotar(bruto, segredo).get("itens", [])
42
+ except (TrancaInvalida, binascii.Error) as erro:
43
+ raise ErroPuxar(f"lista fora da tranca: {erro}")
44
+ return itens if isinstance(itens, list) else []
45
+ finally:
46
+ linha.fechar()
47
+
48
+
49
+ def _pedir_pedaco(
50
+ linha: _Linha, nome: str, parte: int, partes: int, segredo: str
51
+ ) -> tuple[bool, bytes | str]:
52
+ """Pede UM pedaço pela /buscar e devolve (ok, bytes-decifrados) ou (False, motivo)."""
53
+ carga = {"nome": nome, "parte": parte, "partes": partes}
54
+ try:
55
+ status, bruto = linha.postar("/buscar", empacotar(carga, segredo))
56
+ except (http.client.HTTPException, OSError) as erro:
57
+ return False, f"não alcancei a origem ({erro})"
58
+ if status != 200:
59
+ return False, f"origem recusou ({status}): {bruto.decode('utf-8', 'replace')}"
60
+ try:
61
+ volta = desempacotar(bruto, segredo)
62
+ cifrado = base64.b64decode(volta["conteudo"], validate=True)
63
+ return True, decifrar(cifrado, segredo)
64
+ except (TrancaInvalida, KeyError, binascii.Error, InvalidTag) as erro:
65
+ return False, f"pedaço fora da tranca: {erro}"
66
+
67
+
68
+ def puxar(
69
+ nome: str, url: str, segredo: str, base: Path, tamanho: int | None = None
70
+ ) -> tuple[bool, str]:
71
+ """Puxa o arquivo `nome` da `url` pra dentro de `base` (lacrado/cifrado no caminho).
72
+ Arquivo grande vem PICADO e é remontado pela caixa postal. Se um download anterior
73
+ caiu no meio, RETOMA de onde parou (pula os pedaços que já estão no `.parcial` local).
74
+ Se `tamanho` não vier, descobre via /listar. Devolve (ok, destino-ou-motivo)."""
75
+ if tamanho is None:
76
+ try:
77
+ disponivel = {i["nome"]: i["tamanho"] for i in listar(url, segredo)}
78
+ except ErroPuxar as erro:
79
+ return False, str(erro)
80
+ if nome not in disponivel:
81
+ return False, f"'{nome}' não está disponível pra puxar nessa origem"
82
+ tamanho = int(disponivel[nome])
83
+
84
+ # acervo.PEDACO é a FONTE ÚNICA do tamanho do pedaço (servidor e cliente leem o
85
+ # mesmo) — ler em tempo de execução evita cópias dessincronizadas das duas pontas.
86
+ partes = max(1, (tamanho + acervo.PEDACO - 1) // acervo.PEDACO)
87
+ # RETOMADA: consulta o progresso LOCAL (eu mesmo gravo) e pula o que já baixei.
88
+ ja = caixa_postal.progresso_de(nome, partes, base, tamanho)
89
+ linha = _Linha(url)
90
+ destino = None
91
+ try:
92
+ for parte in range(ja, partes):
93
+ ok, payload = _pedir_pedaco(linha, nome, parte, partes, segredo)
94
+ if not ok:
95
+ return False, f"pedaço {parte + 1}/{partes}: {payload} (rode o pull de novo pra retomar)"
96
+ # grava reusando a caixa postal: mesma cerca + remontagem do empurrar.
97
+ destino = caixa_postal.guardar_pedaco(nome, payload, parte, partes, base, tamanho)
98
+ except (OSError, ValueError) as erro:
99
+ return False, f"não consegui gravar: {erro}"
100
+ finally:
101
+ linha.fechar()
102
+ return True, str(destino) if destino else "(nada veio)"
@@ -0,0 +1,136 @@
1
+ """A caixa postal do Ekodide: grava com segurança o que CHEGA pela rede.
2
+
3
+ Lógica pura (sem rede, sem variáveis de ambiente): recebe uma carga já aberta
4
+ pelo lacre e grava DENTRO de uma pasta `base` que o chamador escolhe. Um ponto de
5
+ escrita é poder, então a pasta é cercada:
6
+
7
+ - travessia de caminho ('../') é descartada — nunca escapa da base;
8
+ - arquivo existente NÃO é sobrescrito (ganha sufixo ' (1)', ' (2)'…);
9
+ - arquivo grande chega PICADO e é remontado num '.parcial' até o último pedaço.
10
+
11
+ Quem expõe isso pela rede (HTTP) é o recebedor (recebedor.py). A caixa postal só
12
+ sabe gravar — é a parte mais reaproveitável.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ from pathlib import Path
18
+
19
+
20
+ def _nome_livre(base: Path, nome: str) -> Path:
21
+ """Um caminho que ainda não existe dentro de `base`: 'foto.png', 'foto (1).png'…
22
+ Nunca sobrescreve um arquivo do usuário."""
23
+ alvo = base / nome
24
+ if not alvo.exists():
25
+ return alvo
26
+ tronco, sufixo = alvo.stem, alvo.suffix
27
+ n = 1
28
+ while (base / f"{tronco} ({n}){sufixo}").exists():
29
+ n += 1
30
+ return base / f"{tronco} ({n}){sufixo}"
31
+
32
+
33
+ def _caminho_seguro(nome: str, base: Path) -> tuple[Path, str]:
34
+ """De um `nome` (que pode ser caminho relativo 'Fotos/sub/img.png') devolve
35
+ (pasta_destino, nome_do_arquivo) DENTRO da base, NUNCA escapando dela:
36
+ componentes perigosos ('..', raiz absoluta) são descartados e o resultado é
37
+ conferido contra a base. Levanta ValueError se o nome for inválido."""
38
+ rel = Path(nome)
39
+ if rel.is_absolute(): # caminho absoluto vira relativo (tira a raiz '/')
40
+ rel = Path(*rel.parts[1:])
41
+ # descarta componentes vazios/perigosos ('', '.', '..') -> não dá pra subir de pasta
42
+ partes = [p for p in rel.parts if p not in ("", ".", "..")]
43
+ if not partes:
44
+ raise ValueError("nome de arquivo inválido")
45
+ alvo_dir = base.joinpath(*partes[:-1])
46
+ # cinto e suspensório: a pasta resolvida tem que ficar DENTRO da base
47
+ if base != alvo_dir.resolve() and base not in alvo_dir.resolve().parents:
48
+ raise ValueError("destino fora da pasta permitida")
49
+ return alvo_dir, partes[-1]
50
+
51
+
52
+ def guardar(nome: str, conteudo: bytes, base: Path) -> Path:
53
+ """Grava um arquivo INTEIRO dentro de `base` (recria subpastas, sem escapar,
54
+ sem sobrescrever)."""
55
+ base = base.resolve()
56
+ alvo_dir, filename = _caminho_seguro(nome, base)
57
+ alvo = _nome_livre(alvo_dir, filename)
58
+ alvo_dir.mkdir(parents=True, exist_ok=True)
59
+ alvo.write_bytes(conteudo)
60
+ return alvo
61
+
62
+
63
+ def _ler_progresso(alvo_dir: Path, filename: str, partes: int, tamanho: int) -> int:
64
+ """Quantos pedaços contíguos já temos no '.parcial' — lido do anotador
65
+ '.parcial.meta'. Só vale se o meta casar com ESTE arquivo (mesmo nº de partes e
66
+ mesmo tamanho total); senão devolve 0 (é outro arquivo de mesmo nome → recomeça)."""
67
+ parcial = alvo_dir / (filename + ".parcial")
68
+ meta = alvo_dir / (filename + ".parcial.meta")
69
+ if not (parcial.exists() and meta.exists()):
70
+ return 0
71
+ try:
72
+ mp, mr, mt = meta.read_text().split()
73
+ if int(mp) == partes and int(mt) == tamanho:
74
+ return max(0, min(int(mr), partes))
75
+ except (ValueError, OSError):
76
+ pass
77
+ return 0
78
+
79
+
80
+ def progresso_de(nome: str, partes: int, base: Path, tamanho: int = -1) -> int:
81
+ """Quantos pedaços deste arquivo o destino já tem (pro carteiro RETOMAR de onde
82
+ parou). 0 = nada ainda / arquivo diferente. Mesma cerca de segurança do guardar."""
83
+ base = base.resolve()
84
+ alvo_dir, filename = _caminho_seguro(nome, base)
85
+ return _ler_progresso(alvo_dir, filename, partes, tamanho)
86
+
87
+
88
+ def guardar_pedaco(
89
+ nome: str, conteudo: bytes, parte: int, partes: int, base: Path, tamanho: int = -1
90
+ ) -> Path | None:
91
+ """Recebe UM pedaço de um arquivo grande e vai montando num arquivo temporário
92
+ '.parcial', anotando o progresso no '.parcial.meta' (pra dar pra RETOMAR depois).
93
+ No ÚLTIMO pedaço fecha, renomeia pro nome final (sem sobrescrever), apaga o meta e
94
+ devolve o caminho. Enquanto monta, devolve None.
95
+
96
+ Retomada/idempotência: o pedaço já recebido é ignorado (reenvio após queda da rede
97
+ não corrompe); pedaço fora de ordem (pulou um) é recusado. Mesma cerca do guardar."""
98
+ base = base.resolve()
99
+ alvo_dir, filename = _caminho_seguro(nome, base)
100
+ alvo_dir.mkdir(parents=True, exist_ok=True)
101
+ parcial = alvo_dir / (filename + ".parcial")
102
+ meta = alvo_dir / (filename + ".parcial.meta")
103
+
104
+ recebidos = _ler_progresso(alvo_dir, filename, partes, tamanho)
105
+ if parte < recebidos: # já temos esse pedaço — reenvio repetido, ignora
106
+ return None
107
+ if parte > recebidos: # pulou um pedaço: não dá pra anexar com buraco
108
+ raise ValueError(f"pedaço fora de ordem (esperava {recebidos}, veio {parte})")
109
+
110
+ with parcial.open("wb" if parte == 0 else "ab") as f:
111
+ f.write(conteudo)
112
+ recebidos = parte + 1
113
+
114
+ if recebidos >= partes: # último pedaço: vira o arquivo final
115
+ final = _nome_livre(alvo_dir, filename)
116
+ parcial.replace(final)
117
+ meta.unlink(missing_ok=True)
118
+ return final
119
+ meta.write_text(f"{partes} {recebidos} {tamanho}")
120
+ return None
121
+
122
+
123
+ def gravar_recebido(carga: dict, base: Path) -> Path | None:
124
+ """Grava uma carga já desempacotada (do envelope assinado) dentro de `base`.
125
+ Se vier 'partes', é um arquivo grande picado (monta pedaço a pedaço; destino só
126
+ no último); senão é o arquivo inteiro. Levanta KeyError/ValueError em carga
127
+ inválida."""
128
+ nome = carga["nome"]
129
+ conteudo = base64.b64decode(carga["conteudo"], validate=True)
130
+ if "partes" in carga:
131
+ parte, partes = int(carga["parte"]), int(carga["partes"])
132
+ tamanho = int(carga.get("tamanho", -1)) # total do arquivo (pra travar a retomada)
133
+ if partes < 1 or parte < 0 or parte >= partes:
134
+ raise ValueError("índice de pedaço inválido")
135
+ return guardar_pedaco(nome, conteudo, parte, partes, base, tamanho)
136
+ return guardar(nome, conteudo, base)
ekodide/carteiro.py ADDED
@@ -0,0 +1,216 @@
1
+ """O carteiro do Ekodide: ENVIA um arquivo ou uma pasta inteira pela rede.
2
+
3
+ Lacra cada bloco (HMAC) e posta na rota /receber do destino. Arquivo que cabe vai
4
+ de uma vez; arquivo grande vai PICADO em pedaços (lidos do disco aos poucos, sem
5
+ carregar tudo na memória) — a caixa postal do outro lado remonta. Pasta vira vários
6
+ envios preservando as subpastas (o caminho relativo viaja junto).
7
+
8
+ Devolve um EnvioResultado neutro (números e caminhos), pra quem aciona montar a
9
+ própria resposta. Não lê variáveis de ambiente: recebe a URL e o segredo prontos.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import binascii
15
+ import http.client
16
+ import time
17
+ import urllib.parse
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ from .cofre import cifrar
22
+ from .lacre import TrancaInvalida, desempacotar, empacotar
23
+
24
+ TIMEOUT_S = 30 # mata o POST se a rede travar
25
+
26
+ # Tamanho do PEDAÇO ao mandar um arquivo grande. Fica abaixo do corpo que o
27
+ # recebedor aceita (32 MB), já contando que o base64 incha ~33%: 16 MB viram
28
+ # ~21,3 MB no fio. Arquivo <= isto vai num envio só; maior, vai em pedaços.
29
+ # Pedaço maior = menos idas-e-voltas de rede por arquivo (mais rápido), ainda
30
+ # longe do limite de 32 MB e leve de RAM (processa um por vez).
31
+ PEDACO = 16 * 1024 * 1024
32
+
33
+ # Reenvio de UM pedaço quando a rede pisca: tenta algumas vezes com espera
34
+ # crescente antes de desistir. Se desistir, o '.parcial' fica no destino e um
35
+ # novo `send` RETOMA de onde parou (não recomeça do zero).
36
+ TENTATIVAS = 4 # tentativas por pedaço (1ª + 3 reenvios)
37
+ ESPERA_S = 1.0 # espera-base entre tentativas (cresce a cada uma)
38
+
39
+
40
+ @dataclass
41
+ class EnvioResultado:
42
+ """O que o carteiro devolve — dados crus, sem frase pronta. `ok` resume; o resto
43
+ deixa o chamador montar a mensagem que quiser."""
44
+
45
+ ok: bool
46
+ is_pasta: bool
47
+ total: int # quantos arquivos tentou enviar
48
+ enviados: int # quantos foram de fato
49
+ destino: str = "" # caminho de exemplo no destino (último/único)
50
+ falhas: list[str] = field(default_factory=list) # motivos, vazio = tudo ok
51
+
52
+
53
+ class _Linha:
54
+ """Conexão HTTP reaproveitada (keep-alive): mantém a porta aberta e passa vários
55
+ pedaços pela MESMA conexão, em vez de reabrir uma a cada pedaço — corta o aperto
56
+ de mão de TCP repetido (mais rápido no Wi-Fi real). Reabre sozinha se a rede cair."""
57
+
58
+ def __init__(self, url: str) -> None:
59
+ alvo = urllib.parse.urlsplit(url)
60
+ self._host = alvo.hostname or "127.0.0.1"
61
+ self._porta = alvo.port or 80
62
+ self._conn: http.client.HTTPConnection | None = None
63
+
64
+ def postar(self, caminho: str, corpo: bytes) -> tuple[int, bytes]:
65
+ """POST de um corpo já lacrado. Devolve (status, resposta). Levanta
66
+ HTTPException/OSError se a rede falhar — aí a conexão é descartada pra a
67
+ próxima tentativa reabrir limpa."""
68
+ if self._conn is None:
69
+ self._conn = http.client.HTTPConnection(self._host, self._porta, timeout=TIMEOUT_S)
70
+ try:
71
+ self._conn.request(
72
+ "POST", caminho, body=corpo,
73
+ headers={"Content-Type": "application/json", "Content-Length": str(len(corpo))},
74
+ )
75
+ resp = self._conn.getresponse()
76
+ return resp.status, resp.read()
77
+ except (http.client.HTTPException, OSError):
78
+ self.fechar()
79
+ raise
80
+
81
+ def fechar(self) -> None:
82
+ if self._conn is not None:
83
+ try:
84
+ self._conn.close()
85
+ except OSError:
86
+ pass
87
+ self._conn = None
88
+
89
+
90
+ def _postar(
91
+ linha: _Linha, nome: str, dados: bytes, segredo: str,
92
+ parte: int | None = None, partes: int | None = None, tamanho: int | None = None,
93
+ ) -> tuple[bool, str]:
94
+ """Lacra UM bloco e manda pela `linha` (rota /receber). Se `partes` vier, é um
95
+ PEDAÇO (índice `parte` de `partes`, com o `tamanho` total junto pra travar a
96
+ retomada); senão é o arquivo inteiro de uma vez. `nome` pode ser caminho relativo
97
+ ('Fotos/sub/img.png'). Devolve (ok, info): info é o destino (preenchido no último
98
+ pedaço) ou o motivo da falha."""
99
+ # CIFRA o conteúdo antes de mandar: na rede passa só embaralhado (o cofre). A
100
+ # chave sai do segredo; o destino decifra e grava byte-idêntico ao original.
101
+ carga = {"nome": nome, "conteudo": base64.b64encode(cifrar(dados, segredo)).decode("ascii")}
102
+ if partes is not None:
103
+ carga["parte"], carga["partes"] = parte, partes
104
+ if tamanho is not None:
105
+ carga["tamanho"] = tamanho
106
+ try:
107
+ status, bruto = linha.postar("/receber", empacotar(carga, segredo))
108
+ except (http.client.HTTPException, OSError) as erro:
109
+ return False, f"não alcancei o destino ({erro})"
110
+ if status != 200:
111
+ return False, f"destino recusou ({status}): {bruto.decode('utf-8', 'replace')}"
112
+ try:
113
+ volta = desempacotar(bruto, segredo)
114
+ except (TrancaInvalida, binascii.Error) as erro:
115
+ return False, f"resposta fora da tranca: {erro}"
116
+ return True, str(volta.get("destino") or "")
117
+
118
+
119
+ def _postar_resiliente(
120
+ linha: _Linha, nome: str, dados: bytes, segredo: str,
121
+ parte: int | None = None, partes: int | None = None, tamanho: int | None = None,
122
+ ) -> tuple[bool, str]:
123
+ """Como `_postar`, mas reenvia os MESMOS bytes algumas vezes (espera crescente)
124
+ se a rede piscar. Reenviar um pedaço já gravado é inofensivo — o recebedor é
125
+ idempotente. Só desiste depois de TENTATIVAS falhas seguidas."""
126
+ info = ""
127
+ for tentativa in range(TENTATIVAS):
128
+ ok, info = _postar(linha, nome, dados, segredo, parte, partes, tamanho)
129
+ if ok:
130
+ return True, info
131
+ if tentativa < TENTATIVAS - 1:
132
+ time.sleep(ESPERA_S * (tentativa + 1))
133
+ return False, info
134
+
135
+
136
+ def _ja_recebidos(linha: _Linha, nome: str, segredo: str, partes: int, tamanho: int) -> int:
137
+ """Pergunta ao destino quantos pedaços deste arquivo ele já tem (pra RETOMAR).
138
+ Qualquer erro na consulta → 0 (começa do zero, sem nunca atrapalhar o envio)."""
139
+ carga = {"nome": nome, "partes": partes, "tamanho": tamanho}
140
+ try:
141
+ status, bruto = linha.postar("/progresso", empacotar(carga, segredo))
142
+ if status != 200:
143
+ return 0
144
+ volta = desempacotar(bruto, segredo)
145
+ return max(0, min(int(volta.get("recebidos", 0)), partes))
146
+ except (http.client.HTTPException, OSError, TrancaInvalida, binascii.Error, ValueError):
147
+ return 0
148
+
149
+
150
+ def _enviar_arquivo(origem: Path, nome: str, url: str, segredo: str) -> tuple[bool, str]:
151
+ """Envia UM arquivo por UMA conexão reaproveitada (keep-alive). Se couber num
152
+ pedaço, vai de uma vez; se for grande, vai PICADO (lendo do disco aos pedaços, sem
153
+ carregar tudo na memória). Pergunta ao destino o que ele já tem e RETOMA de onde
154
+ parou; cada pedaço é reenviado se a rede piscar. Devolve (ok, info)."""
155
+ tamanho = origem.stat().st_size
156
+ linha = _Linha(url)
157
+ try:
158
+ if tamanho <= PEDACO:
159
+ return _postar_resiliente(linha, nome, origem.read_bytes(), segredo)
160
+
161
+ partes = (tamanho + PEDACO - 1) // PEDACO
162
+ inicio = _ja_recebidos(linha, nome, segredo, partes, tamanho) # RETOMA daqui
163
+ destino = ""
164
+ with origem.open("rb") as f:
165
+ if inicio:
166
+ f.seek(inicio * PEDACO) # pula os pedaços que o destino já tem
167
+ for i in range(inicio, partes):
168
+ bloco = f.read(PEDACO) # lê UMA vez; o reenvio usa estes mesmos bytes
169
+ ok, info = _postar_resiliente(linha, nome, bloco, segredo, i, partes, tamanho)
170
+ if not ok:
171
+ return False, f"pedaço {i + 1}/{partes}: {info} (rode o `send` de novo pra retomar)"
172
+ destino = info or destino
173
+ return True, destino or "(montado no destino)"
174
+ finally:
175
+ linha.fechar()
176
+
177
+
178
+ def _enviar_pasta(raiz: Path, url: str, segredo: str) -> EnvioResultado:
179
+ """Percorre a pasta e envia cada arquivo preservando a estrutura (o nome da
180
+ pasta + subpastas viram o caminho relativo). Arquivo grande vai picado."""
181
+ arquivos = sorted(x for x in raiz.rglob("*") if x.is_file())
182
+ if not arquivos:
183
+ return EnvioResultado(ok=True, is_pasta=True, total=0, enviados=0)
184
+
185
+ enviados, falhas, exemplo = 0, [], ""
186
+ for arq in arquivos:
187
+ rel = Path(raiz.name) / arq.relative_to(raiz) # 'Fotos/sub/img.png'
188
+ try:
189
+ ok, info = _enviar_arquivo(arq, str(rel), url, segredo)
190
+ except OSError as erro:
191
+ falhas.append(f"{rel} (não li: {erro})")
192
+ continue
193
+ if ok:
194
+ enviados += 1
195
+ exemplo = exemplo or info
196
+ else:
197
+ falhas.append(f"{rel} ({info})")
198
+
199
+ return EnvioResultado(
200
+ ok=enviados > 0, is_pasta=True, total=len(arquivos),
201
+ enviados=enviados, destino=exemplo, falhas=falhas,
202
+ )
203
+
204
+
205
+ def enviar(origem: Path, url: str, segredo: str) -> EnvioResultado:
206
+ """Manda um arquivo OU uma pasta inteira pra `url` (rota /receber), lacrado com
207
+ `segredo`. Arquivo grande vai picado; pasta preserva as subpastas. Devolve um
208
+ EnvioResultado neutro — sem frase pronta, pra o chamador montar a sua."""
209
+ origem = Path(origem)
210
+ if origem.is_dir():
211
+ return _enviar_pasta(origem, url, segredo)
212
+ ok, info = _enviar_arquivo(origem, origem.name, url, segredo) # grande vai picado
213
+ return EnvioResultado(
214
+ ok=ok, is_pasta=False, total=1, enviados=1 if ok else 0,
215
+ destino=info if ok else "", falhas=[] if ok else [info],
216
+ )