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 +56 -0
- ekodide/acervo.py +84 -0
- ekodide/buscador.py +102 -0
- ekodide/caixa_postal.py +136 -0
- ekodide/carteiro.py +216 -0
- ekodide/cli.py +391 -0
- ekodide/cofre.py +44 -0
- ekodide/config.py +84 -0
- ekodide/cortina.py +155 -0
- ekodide/frase.py +48 -0
- ekodide/lacre.py +85 -0
- ekodide/recebedor.py +186 -0
- ekodide/vizinhanca.py +113 -0
- ekodide-0.1.0.dist-info/METADATA +258 -0
- ekodide-0.1.0.dist-info/RECORD +19 -0
- ekodide-0.1.0.dist-info/WHEEL +5 -0
- ekodide-0.1.0.dist-info/entry_points.txt +2 -0
- ekodide-0.1.0.dist-info/licenses/LICENSE +21 -0
- ekodide-0.1.0.dist-info/top_level.txt +1 -0
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)"
|
ekodide/caixa_postal.py
ADDED
|
@@ -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
|
+
)
|