cuboutils 0.1.0__tar.gz

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.
@@ -0,0 +1,88 @@
1
+ # Formato da licença
2
+
3
+ O emissor gera um blob JSON UTF-8 assinado com Ed25519. O instalador grava esse blob, sem extensão, em:
4
+
5
+ ```text
6
+ C:\ProgramData\CUBO\-
7
+ ```
8
+
9
+ O caminho de `ProgramData` é obtido pela API `SHGetKnownFolderPath` do Windows, portanto não depende da variável de ambiente `%PROGRAMDATA%` nem de o Windows estar instalado na unidade `C:`.
10
+
11
+ ## Dados assinados
12
+
13
+ O `payload` possui exatamente três campos:
14
+
15
+ ```json
16
+ {
17
+ "expires_at": "2027-09-04T23:59:59Z",
18
+ "machine_id": "HW1-ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRST",
19
+ "products": ["OutroPrograma", "TQSVigas"]
20
+ }
21
+ ```
22
+
23
+ ### `machine_id`
24
+
25
+ - identifica o computador autorizado;
26
+ - começa com `HW1-`;
27
+ - após o prefixo, possui exatamente 52 caracteres Base32 maiúsculos, usando `A-Z` e `2-7`;
28
+ - é o SHA-256 dos identificadores normalizados do computador, codificado em Base32;
29
+ - não expõe diretamente UUID SMBIOS, serial da placa-mãe ou `MachineGuid`.
30
+
31
+ ### `products`
32
+
33
+ - é uma lista com pelo menos um produto;
34
+ - é armazenada em ordem lexicográfica e sem duplicatas;
35
+ - cada identificador possui de 1 a 100 caracteres;
36
+ - o primeiro caractere deve ser alfanumérico;
37
+ - os demais podem ser alfanuméricos, ponto, sublinhado ou hífen;
38
+ - diferencia letras maiúsculas de minúsculas.
39
+
40
+ ### `expires_at`
41
+
42
+ - indica o instante final da validade;
43
+ - usa UTC e não possui microssegundos;
44
+ - segue exatamente o formato `YYYY-MM-DDTHH:MM:SSZ`.
45
+
46
+ ## Envelope armazenado
47
+
48
+ O arquivo `-` contém exatamente:
49
+
50
+ ```json
51
+ {
52
+ "payload": "BASE64URL_SEM_PADDING",
53
+ "signature": "ASSINATURA_ED25519_EM_BASE64URL"
54
+ }
55
+ ```
56
+
57
+ - `payload` é o JSON descrito acima, codificado em Base64URL sem `=` no final;
58
+ - `signature` é a assinatura Ed25519 dos bytes exatos do `payload`, também codificada em Base64URL sem `=`;
59
+ - a assinatura decodificada possui exatamente 64 bytes;
60
+ - o emissor produz JSON compacto, com chaves ordenadas e sem espaços desnecessários;
61
+ - o envelope produzido termina com uma quebra de linha.
62
+
63
+ O limite é de 4 KiB para o `payload` e 16 KiB para o blob completo.
64
+
65
+ ## Armazenamento
66
+
67
+ `license.storage.save_license()` grava o blob em `C:\ProgramData\CUBO\-` e aplica os atributos `Hidden` e `System`. Esses atributos apenas reduzem a visibilidade no Explorador de Arquivos; não impedem leitura, cópia ou exclusão.
68
+
69
+ ## Validação
70
+
71
+ `license` libera um produto somente quando:
72
+
73
+ 1. o blob e o `payload` possuem a estrutura esperada;
74
+ 2. a assinatura é válida para a chave pública incorporada;
75
+ 3. o `machine_id` assinado coincide com o computador atual;
76
+ 4. o produto solicitado aparece em `products`;
77
+ 5. a hora atual não ultrapassou `expires_at`.
78
+
79
+ O arquivo não é criptografado. Qualquer pessoa pode decodificar e ler seu conteúdo, mas uma alteração no `machine_id`, em `products` ou em `expires_at` invalida a assinatura.
80
+
81
+ ## Responsabilidades
82
+
83
+ - `license.issuer`: monta o `payload` e o assina com a chave privada recebida por argumento;
84
+ - `license.machine`: calcula o código da máquina;
85
+ - `license.storage`: lê e instala o blob;
86
+ - `license.verifier`: valida o blob com a chave pública incorporada.
87
+
88
+ Durante este teste de conceito, a chave privada está na raiz do repositório. Em produção, ela nunca deve fazer parte do repositório, do instalador ou dos programas consumidores.
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: cuboutils
3
+ Version: 0.1.0
4
+ Summary: Utilitários compartilhados pelos produtos CUBO
5
+ Author: Leonardo Pires Batista
6
+ Author-email: leonardopbatista98@gmail.com
7
+ Keywords: cubo licença atualização
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE_FORMAT.md
11
+ Requires-Dist: cryptography==50.0.1
12
+ Dynamic: author
13
+ Dynamic: author-email
14
+ Dynamic: description
15
+ Dynamic: description-content-type
16
+ Dynamic: keywords
17
+ Dynamic: license-file
18
+ Dynamic: requires-dist
19
+ Dynamic: requires-python
20
+ Dynamic: summary
21
+
22
+ # CuboUtils
23
+
24
+ Este repositório contém utilitários compartilhados pelos produtos CUBO:
25
+
26
+ - `license/`: emissão, instalação e validação de licenças;
27
+ - `update_checker/`: consulta da versão mais recente de um produto.
28
+
29
+ ## Licenças
30
+
31
+ A diferença entre os programas está somente nas operações utilizadas:
32
+
33
+ - o consumidor chama `verify_installed_license()`;
34
+ - o instalador chama `current_machine_id()` e `save_license()`;
35
+ - o emissor chama `issue_license()` e fornece sua chave privada.
36
+
37
+ A chave pública fictícia está incorporada em `license/public_key.py`. Durante este teste de conceito, a chave privada correspondente está em `private-key.pem`, na raiz do repositório.
38
+
39
+ As duas chaves atuais são apenas para desenvolvimento. Antes de qualquer distribuição real, remova `private-key.pem` do repositório e substitua o par por chaves mantidas em um ambiente seguro.
40
+
41
+ A licença instalada usa o caminho fixo `C:\ProgramData\CUBO\-`. `save_license()` grava o arquivo com os atributos `Hidden` e `System`, enquanto os programas consumidores usam somente a interface de validação.
42
+
43
+ O formato compartilhado está descrito em `LICENSE_FORMAT.md`, e os exemplos de cada operação estão em `license/README.md`.
44
+
45
+ ## Verificação de versão
46
+
47
+ O endpoint retorna um único objeto JSON que relaciona cada produto à sua versão mais recente:
48
+
49
+ ```json
50
+ {
51
+ "TQSVigas": "v1.0.0",
52
+ "TQSFormas": "v1.0.0"
53
+ }
54
+ ```
55
+
56
+ O utilitário retorna `True` quando a versão informada é igual ou posterior à versão publicada:
57
+
58
+ ```python
59
+ from update_checker import is_latest_version
60
+
61
+ updated = is_latest_version(
62
+ endpoint="https://leonardopbatista.github.io/versions/versions.json",
63
+ product="TQSVigas",
64
+ version="2027.2.0",
65
+ )
66
+ ```
67
+
68
+ Versões aceitas são sequências numéricas separadas por pontos, com um `v` inicial opcional. A consulta possui timeout padrão de cinco segundos e lança `UpdateCheckError` quando a rede ou a resposta do servidor falha.
@@ -0,0 +1,47 @@
1
+ # CuboUtils
2
+
3
+ Este repositório contém utilitários compartilhados pelos produtos CUBO:
4
+
5
+ - `license/`: emissão, instalação e validação de licenças;
6
+ - `update_checker/`: consulta da versão mais recente de um produto.
7
+
8
+ ## Licenças
9
+
10
+ A diferença entre os programas está somente nas operações utilizadas:
11
+
12
+ - o consumidor chama `verify_installed_license()`;
13
+ - o instalador chama `current_machine_id()` e `save_license()`;
14
+ - o emissor chama `issue_license()` e fornece sua chave privada.
15
+
16
+ A chave pública fictícia está incorporada em `license/public_key.py`. Durante este teste de conceito, a chave privada correspondente está em `private-key.pem`, na raiz do repositório.
17
+
18
+ As duas chaves atuais são apenas para desenvolvimento. Antes de qualquer distribuição real, remova `private-key.pem` do repositório e substitua o par por chaves mantidas em um ambiente seguro.
19
+
20
+ A licença instalada usa o caminho fixo `C:\ProgramData\CUBO\-`. `save_license()` grava o arquivo com os atributos `Hidden` e `System`, enquanto os programas consumidores usam somente a interface de validação.
21
+
22
+ O formato compartilhado está descrito em `LICENSE_FORMAT.md`, e os exemplos de cada operação estão em `license/README.md`.
23
+
24
+ ## Verificação de versão
25
+
26
+ O endpoint retorna um único objeto JSON que relaciona cada produto à sua versão mais recente:
27
+
28
+ ```json
29
+ {
30
+ "TQSVigas": "v1.0.0",
31
+ "TQSFormas": "v1.0.0"
32
+ }
33
+ ```
34
+
35
+ O utilitário retorna `True` quando a versão informada é igual ou posterior à versão publicada:
36
+
37
+ ```python
38
+ from update_checker import is_latest_version
39
+
40
+ updated = is_latest_version(
41
+ endpoint="https://leonardopbatista.github.io/versions/versions.json",
42
+ product="TQSVigas",
43
+ version="2027.2.0",
44
+ )
45
+ ```
46
+
47
+ Versões aceitas são sequências numéricas separadas por pontos, com um `v` inicial opcional. A consulta possui timeout padrão de cinco segundos e lança `UpdateCheckError` quando a rede ou a resposta do servidor falha.
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: cuboutils
3
+ Version: 0.1.0
4
+ Summary: Utilitários compartilhados pelos produtos CUBO
5
+ Author: Leonardo Pires Batista
6
+ Author-email: leonardopbatista98@gmail.com
7
+ Keywords: cubo licença atualização
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE_FORMAT.md
11
+ Requires-Dist: cryptography==50.0.1
12
+ Dynamic: author
13
+ Dynamic: author-email
14
+ Dynamic: description
15
+ Dynamic: description-content-type
16
+ Dynamic: keywords
17
+ Dynamic: license-file
18
+ Dynamic: requires-dist
19
+ Dynamic: requires-python
20
+ Dynamic: summary
21
+
22
+ # CuboUtils
23
+
24
+ Este repositório contém utilitários compartilhados pelos produtos CUBO:
25
+
26
+ - `license/`: emissão, instalação e validação de licenças;
27
+ - `update_checker/`: consulta da versão mais recente de um produto.
28
+
29
+ ## Licenças
30
+
31
+ A diferença entre os programas está somente nas operações utilizadas:
32
+
33
+ - o consumidor chama `verify_installed_license()`;
34
+ - o instalador chama `current_machine_id()` e `save_license()`;
35
+ - o emissor chama `issue_license()` e fornece sua chave privada.
36
+
37
+ A chave pública fictícia está incorporada em `license/public_key.py`. Durante este teste de conceito, a chave privada correspondente está em `private-key.pem`, na raiz do repositório.
38
+
39
+ As duas chaves atuais são apenas para desenvolvimento. Antes de qualquer distribuição real, remova `private-key.pem` do repositório e substitua o par por chaves mantidas em um ambiente seguro.
40
+
41
+ A licença instalada usa o caminho fixo `C:\ProgramData\CUBO\-`. `save_license()` grava o arquivo com os atributos `Hidden` e `System`, enquanto os programas consumidores usam somente a interface de validação.
42
+
43
+ O formato compartilhado está descrito em `LICENSE_FORMAT.md`, e os exemplos de cada operação estão em `license/README.md`.
44
+
45
+ ## Verificação de versão
46
+
47
+ O endpoint retorna um único objeto JSON que relaciona cada produto à sua versão mais recente:
48
+
49
+ ```json
50
+ {
51
+ "TQSVigas": "v1.0.0",
52
+ "TQSFormas": "v1.0.0"
53
+ }
54
+ ```
55
+
56
+ O utilitário retorna `True` quando a versão informada é igual ou posterior à versão publicada:
57
+
58
+ ```python
59
+ from update_checker import is_latest_version
60
+
61
+ updated = is_latest_version(
62
+ endpoint="https://leonardopbatista.github.io/versions/versions.json",
63
+ product="TQSVigas",
64
+ version="2027.2.0",
65
+ )
66
+ ```
67
+
68
+ Versões aceitas são sequências numéricas separadas por pontos, com um `v` inicial opcional. A consulta possui timeout padrão de cinco segundos e lança `UpdateCheckError` quando a rede ou a resposta do servidor falha.
@@ -0,0 +1,21 @@
1
+ LICENSE_FORMAT.md
2
+ README.md
3
+ setup.py
4
+ cuboutils.egg-info/PKG-INFO
5
+ cuboutils.egg-info/SOURCES.txt
6
+ cuboutils.egg-info/dependency_links.txt
7
+ cuboutils.egg-info/requires.txt
8
+ cuboutils.egg-info/top_level.txt
9
+ license/__init__.py
10
+ license/contract.py
11
+ license/issuer.py
12
+ license/machine.py
13
+ license/public_key.py
14
+ license/storage.py
15
+ license/verifier.py
16
+ tests/test_license_flow.py
17
+ tests/test_machine.py
18
+ tests/test_storage.py
19
+ tests/test_update_checker.py
20
+ update_checker/__init__.py
21
+ update_checker/checker.py
@@ -0,0 +1 @@
1
+ cryptography==50.0.1
@@ -0,0 +1,2 @@
1
+ license
2
+ update_checker
@@ -0,0 +1,22 @@
1
+ """Emissão, instalação e validação das licenças dos produtos CUBO."""
2
+
3
+ from license.contract import LicenseClaims, LicenseContractError
4
+ from license.issuer import issue_license
5
+ from license.machine import MachineFingerprintError, current_machine_id
6
+ from license.storage import LICENSE_PATH, load_license, save_license
7
+ from license.verifier import LicenseStatus, LicenseVerification, verify_installed_license, verify_license
8
+
9
+ __all__ = [
10
+ "LICENSE_PATH",
11
+ "LicenseClaims",
12
+ "LicenseContractError",
13
+ "LicenseStatus",
14
+ "LicenseVerification",
15
+ "MachineFingerprintError",
16
+ "current_machine_id",
17
+ "issue_license",
18
+ "load_license",
19
+ "save_license",
20
+ "verify_installed_license",
21
+ "verify_license",
22
+ ]
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+
10
+
11
+ MACHINE_ID_PATTERN = re.compile(r"HW1-[A-Z2-7]{52}")
12
+ PRODUCT_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}")
13
+ _PAYLOAD_FIELDS = frozenset({"machine_id", "products", "expires_at"})
14
+ _ENVELOPE_FIELDS = frozenset({"payload", "signature"})
15
+ _MAX_BLOB_SIZE = 16 * 1024
16
+ _MAX_PAYLOAD_SIZE = 4 * 1024
17
+
18
+
19
+ class LicenseContractError(ValueError):
20
+ """Indica que uma licença ou seus dados não obedecem ao formato esperado."""
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class LicenseClaims:
25
+ machine_id: str
26
+ products: tuple[str, ...]
27
+ expires_at: datetime
28
+
29
+ def __post_init__(self) -> None:
30
+ if not isinstance(self.machine_id, str) or not MACHINE_ID_PATTERN.fullmatch(self.machine_id):
31
+ raise LicenseContractError("machine_id é inválido.")
32
+ if not isinstance(self.products, tuple) or not self.products:
33
+ raise LicenseContractError("products deve ser uma tupla com ao menos um produto.")
34
+ if any(not isinstance(product, str) or not PRODUCT_PATTERN.fullmatch(product) for product in self.products):
35
+ raise LicenseContractError("products contém um produto inválido.")
36
+ if not isinstance(self.expires_at, datetime) or self.expires_at.tzinfo is None or self.expires_at.utcoffset() is None:
37
+ raise LicenseContractError("expires_at deve possuir fuso horário.")
38
+ object.__setattr__(self, "products", tuple(sorted(set(self.products))))
39
+ object.__setattr__(self, "expires_at", self.expires_at.astimezone(timezone.utc).replace(microsecond=0))
40
+
41
+ @classmethod
42
+ def from_payload_bytes(cls, payload_bytes: bytes) -> LicenseClaims:
43
+ if not payload_bytes or len(payload_bytes) > _MAX_PAYLOAD_SIZE:
44
+ raise LicenseContractError("O tamanho do payload é inválido.")
45
+ try:
46
+ payload = json.loads(payload_bytes.decode("utf-8"))
47
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
48
+ raise LicenseContractError("O payload não contém JSON UTF-8 válido.") from error
49
+ if not isinstance(payload, dict) or set(payload) != _PAYLOAD_FIELDS:
50
+ raise LicenseContractError("O payload não possui exatamente os campos esperados.")
51
+
52
+ machine_id = payload["machine_id"]
53
+ products = payload["products"]
54
+ expires_at = payload["expires_at"]
55
+ if not isinstance(machine_id, str) or not MACHINE_ID_PATTERN.fullmatch(machine_id):
56
+ raise LicenseContractError("machine_id é inválido.")
57
+ if not isinstance(products, list) or not products:
58
+ raise LicenseContractError("products deve ser uma lista com ao menos um produto.")
59
+ if any(not isinstance(product, str) or not PRODUCT_PATTERN.fullmatch(product) for product in products):
60
+ raise LicenseContractError("products contém um produto inválido.")
61
+ if products != sorted(set(products)):
62
+ raise LicenseContractError("products deve estar ordenado e sem duplicatas.")
63
+ if not isinstance(expires_at, str):
64
+ raise LicenseContractError("expires_at deve ser uma string UTC.")
65
+ try:
66
+ parsed_expiration = datetime.strptime(expires_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
67
+ except ValueError as error:
68
+ raise LicenseContractError("expires_at deve usar o formato YYYY-MM-DDTHH:MM:SSZ.") from error
69
+ return cls(machine_id, tuple(products), parsed_expiration)
70
+
71
+ def to_payload_bytes(self) -> bytes:
72
+ payload = {
73
+ "machine_id": self.machine_id,
74
+ "products": list(self.products),
75
+ "expires_at": self.expires_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
76
+ }
77
+ payload_bytes = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
78
+ if len(payload_bytes) > _MAX_PAYLOAD_SIZE:
79
+ raise LicenseContractError("O tamanho do payload é inválido.")
80
+ return payload_bytes
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class LicenseEnvelope:
85
+ payload: bytes
86
+ signature: bytes
87
+
88
+ @classmethod
89
+ def from_blob(cls, blob: bytes) -> LicenseEnvelope:
90
+ if not blob or len(blob) > _MAX_BLOB_SIZE:
91
+ raise LicenseContractError("O tamanho da licença é inválido.")
92
+ try:
93
+ document = json.loads(blob.decode("utf-8"))
94
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
95
+ raise LicenseContractError("A licença não contém JSON UTF-8 válido.") from error
96
+ if not isinstance(document, dict) or set(document) != _ENVELOPE_FIELDS:
97
+ raise LicenseContractError("A licença não possui exatamente os campos esperados.")
98
+ if any(not isinstance(document[field], str) for field in _ENVELOPE_FIELDS):
99
+ raise LicenseContractError("Os campos da licença devem ser strings.")
100
+ payload = _base64url_decode(document["payload"], "payload")
101
+ signature = _base64url_decode(document["signature"], "signature")
102
+ if not payload or len(payload) > _MAX_PAYLOAD_SIZE:
103
+ raise LicenseContractError("O tamanho do payload é inválido.")
104
+ if len(signature) != 64:
105
+ raise LicenseContractError("A assinatura Ed25519 deve possuir 64 bytes.")
106
+ return cls(payload, signature)
107
+
108
+ def to_blob(self) -> bytes:
109
+ if not self.payload or len(self.payload) > _MAX_PAYLOAD_SIZE:
110
+ raise LicenseContractError("O tamanho do payload é inválido.")
111
+ if len(self.signature) != 64:
112
+ raise LicenseContractError("A assinatura Ed25519 deve possuir 64 bytes.")
113
+ document = {
114
+ "payload": base64.urlsafe_b64encode(self.payload).rstrip(b"=").decode("ascii"),
115
+ "signature": base64.urlsafe_b64encode(self.signature).rstrip(b"=").decode("ascii"),
116
+ }
117
+ blob = json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
118
+ if len(blob) > _MAX_BLOB_SIZE:
119
+ raise LicenseContractError("O tamanho da licença é inválido.")
120
+ return blob
121
+
122
+
123
+ def _base64url_decode(value: str, field_name: str) -> bytes:
124
+ if not value or not re.fullmatch(r"[A-Za-z0-9_-]+", value):
125
+ raise LicenseContractError(f"{field_name} não contém base64url válido.")
126
+ try:
127
+ return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
128
+ except (ValueError, binascii.Error) as error:
129
+ raise LicenseContractError(f"{field_name} não contém base64url válido.") from error
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from cryptography.hazmat.primitives import serialization
4
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
5
+
6
+ from license.contract import LicenseClaims, LicenseContractError, LicenseEnvelope
7
+
8
+
9
+ def issue_license(claims: LicenseClaims, private_key_pem: bytes, password: bytes | None = None) -> bytes:
10
+ """Assina os dados com uma chave privada fornecida pelo ambiente emissor."""
11
+ if not isinstance(claims, LicenseClaims):
12
+ raise TypeError("claims deve ser uma instância de LicenseClaims.")
13
+ try:
14
+ private_key = serialization.load_pem_private_key(private_key_pem, password=password)
15
+ except (TypeError, ValueError) as error:
16
+ raise LicenseContractError("Não foi possível abrir a chave privada.") from error
17
+ if not isinstance(private_key, Ed25519PrivateKey):
18
+ raise LicenseContractError("A chave privada não é Ed25519.")
19
+ payload = claims.to_payload_bytes()
20
+ return LicenseEnvelope(payload, private_key.sign(payload)).to_blob()
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import winreg
10
+ from collections.abc import Mapping
11
+
12
+
13
+ _INVALID_VALUES = frozenset({
14
+ "",
15
+ "none",
16
+ "null",
17
+ "unknown",
18
+ "default string",
19
+ "system serial number",
20
+ "to be filled by o.e.m.",
21
+ "not applicable",
22
+ "not specified",
23
+ "ffffffff-ffff-ffff-ffff-ffffffffffff",
24
+ "00000000-0000-0000-0000-000000000000",
25
+ })
26
+
27
+
28
+ class MachineFingerprintError(RuntimeError):
29
+ """Indica que o computador não forneceu identificadores utilizáveis."""
30
+
31
+
32
+ def current_machine_id() -> str:
33
+ """Calcula o identificador HW1 deste computador Windows."""
34
+ if sys.platform != "win32":
35
+ raise MachineFingerprintError("A coleta automática de identificadores está disponível apenas no Windows.")
36
+ return fingerprint_from_identifiers(collect_windows_identifiers())
37
+
38
+
39
+ def collect_windows_identifiers() -> dict[str, str]:
40
+ """Coleta UUID SMBIOS, serial da placa-mãe e MachineGuid localmente."""
41
+ identifiers = _read_cim_identifiers()
42
+ machine_guid = _read_machine_guid()
43
+ if machine_guid:
44
+ identifiers["machine_guid"] = machine_guid
45
+ normalized = _normalize_identifiers(identifiers)
46
+ if not normalized:
47
+ raise MachineFingerprintError("O Windows não forneceu nenhum identificador de máquina utilizável.")
48
+ return normalized
49
+
50
+
51
+ def fingerprint_from_identifiers(identifiers: Mapping[str, str]) -> str:
52
+ """Produz um hash determinístico sem expor os identificadores originais."""
53
+ normalized = _normalize_identifiers(identifiers)
54
+ if not normalized:
55
+ raise MachineFingerprintError("Nenhum identificador de máquina utilizável foi informado.")
56
+ serialized = json.dumps(normalized, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
57
+ return "HW1-" + base64.b32encode(hashlib.sha256(serialized).digest()).rstrip(b"=").decode("ascii")
58
+
59
+
60
+ def _read_cim_identifiers() -> dict[str, str]:
61
+ script = "$ErrorActionPreference = 'Stop';" + \
62
+ "$board = Get-CimInstance -ClassName Win32_BaseBoard | Select-Object -First 1;" + \
63
+ "$system = Get-CimInstance -ClassName Win32_ComputerSystemProduct | Select-Object -First 1;" + \
64
+ "[PSCustomObject]@{baseboard_serial=$board.SerialNumber;system_uuid=$system.UUID} | ConvertTo-Json -Compress"
65
+ try:
66
+ process = subprocess.run(
67
+ ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
68
+ check=True,
69
+ capture_output=True,
70
+ text=True,
71
+ timeout=15,
72
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
73
+ )
74
+ content = json.loads(process.stdout)
75
+ except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
76
+ return {}
77
+ if not isinstance(content, dict):
78
+ return {}
79
+ return {str(key): str(value) for key, value in content.items() if value is not None}
80
+
81
+
82
+ def _read_machine_guid() -> str:
83
+ try:
84
+ with winreg.OpenKey(
85
+ winreg.HKEY_LOCAL_MACHINE,
86
+ "SOFTWARE\\Microsoft\\Cryptography",
87
+ 0,
88
+ winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
89
+ ) as registry_key:
90
+ value, _value_type = winreg.QueryValueEx(registry_key, "MachineGuid")
91
+ except OSError:
92
+ return ""
93
+ return str(value)
94
+
95
+
96
+ def _normalize_identifiers(identifiers: Mapping[str, str]) -> dict[str, str]:
97
+ normalized: dict[str, str] = {}
98
+ for raw_name, raw_value in identifiers.items():
99
+ name = re.sub(r"[^a-z0-9_]+", "_", str(raw_name).strip().casefold()).strip("_")
100
+ value = " ".join(str(raw_value).strip().casefold().split())
101
+ compact = re.sub(r"[^a-z0-9]", "", value)
102
+ repeated_placeholder = len(compact) >= 8 and len(set(compact)) == 1
103
+ if name and value and value not in _INVALID_VALUES and not repeated_placeholder:
104
+ normalized[name] = value
105
+ return dict(sorted(normalized.items()))
@@ -0,0 +1,4 @@
1
+ PUBLIC_KEY_PEM = b"""-----BEGIN PUBLIC KEY-----
2
+ MCowBQYDK2VwAyEAfmREHLhaOuIc4crBxPi4RvWrEedHl4yNid1l9RvRkds=
3
+ -----END PUBLIC KEY-----
4
+ """
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ from pathlib import Path
5
+ from uuid import UUID
6
+
7
+
8
+ class _GUID(ctypes.Structure):
9
+ _fields_ = [
10
+ ("data1", ctypes.c_uint32),
11
+ ("data2", ctypes.c_uint16),
12
+ ("data3", ctypes.c_uint16),
13
+ ("data4", ctypes.c_ubyte * 8),
14
+ ]
15
+
16
+
17
+ _PROGRAM_DATA_FOLDER_ID = _GUID.from_buffer_copy(UUID("62ab5d82-fdc1-4dc3-a9dd-070d1d495d97").bytes_le)
18
+ _FILE_ATTRIBUTE_HIDDEN = 0x02
19
+ _FILE_ATTRIBUTE_SYSTEM = 0x04
20
+ _INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF
21
+
22
+
23
+ def program_data_path() -> Path:
24
+ """Consulta o caminho real do ProgramData pela API de pastas conhecidas do Windows."""
25
+ shell32 = ctypes.WinDLL("shell32", use_last_error=True)
26
+ ole32 = ctypes.WinDLL("ole32", use_last_error=True)
27
+ get_known_folder_path = shell32.SHGetKnownFolderPath
28
+ get_known_folder_path.argtypes = [ctypes.POINTER(_GUID), ctypes.c_uint32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
29
+ get_known_folder_path.restype = ctypes.c_long
30
+ ole32.CoTaskMemFree.argtypes = [ctypes.c_void_p]
31
+
32
+ path_pointer = ctypes.c_wchar_p()
33
+ result = get_known_folder_path(ctypes.byref(_PROGRAM_DATA_FOLDER_ID), 0, None, ctypes.byref(path_pointer))
34
+ if result != 0:
35
+ raise OSError(f"O Windows não informou o caminho do ProgramData: HRESULT 0x{result & 0xFFFFFFFF:08X}.")
36
+ try:
37
+ return Path(path_pointer.value)
38
+ finally:
39
+ ole32.CoTaskMemFree(path_pointer)
40
+
41
+
42
+ LICENSE_PATH = program_data_path() / "CUBO" / "-"
43
+
44
+
45
+ def load_license() -> bytes:
46
+ """Lê a licença instalada no caminho padrão do CUBO."""
47
+ return LICENSE_PATH.read_bytes()
48
+
49
+
50
+ def save_license(blob: bytes) -> Path:
51
+ """Grava o blob recebido e o oculta como arquivo de sistema no Windows."""
52
+ if not isinstance(blob, bytes) or not blob:
53
+ raise ValueError("A licença deve conter bytes.")
54
+ LICENSE_PATH.parent.mkdir(parents=True, exist_ok=True)
55
+ LICENSE_PATH.write_bytes(blob)
56
+ _set_hidden_and_system(LICENSE_PATH)
57
+ return LICENSE_PATH
58
+
59
+
60
+ def _set_hidden_and_system(path: Path) -> None:
61
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
62
+ get_attributes = kernel32.GetFileAttributesW
63
+ get_attributes.argtypes = [ctypes.c_wchar_p]
64
+ get_attributes.restype = ctypes.c_uint32
65
+ set_attributes = kernel32.SetFileAttributesW
66
+ set_attributes.argtypes = [ctypes.c_wchar_p, ctypes.c_uint32]
67
+ set_attributes.restype = ctypes.c_int
68
+
69
+ absolute_path = str(path.resolve())
70
+ attributes = get_attributes(absolute_path)
71
+ if attributes == _INVALID_FILE_ATTRIBUTES:
72
+ raise ctypes.WinError(ctypes.get_last_error())
73
+ if not set_attributes(absolute_path, attributes | _FILE_ATTRIBUTE_HIDDEN | _FILE_ATTRIBUTE_SYSTEM):
74
+ raise ctypes.WinError(ctypes.get_last_error())
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from enum import StrEnum
7
+
8
+ from cryptography.exceptions import InvalidSignature
9
+ from cryptography.hazmat.primitives import serialization
10
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
11
+
12
+ from license.contract import LicenseClaims, LicenseContractError, LicenseEnvelope
13
+ from license.machine import MachineFingerprintError, current_machine_id
14
+ from license.public_key import PUBLIC_KEY_PEM
15
+ from license.storage import load_license
16
+
17
+
18
+ class LicenseStatus(StrEnum):
19
+ valid = "valid"
20
+ license_not_found = "license_not_found"
21
+ license_read_error = "license_read_error"
22
+ machine_error = "machine_error"
23
+ malformed = "malformed"
24
+ invalid_public_key = "invalid_public_key"
25
+ invalid_signature = "invalid_signature"
26
+ wrong_machine = "wrong_machine"
27
+ wrong_product = "wrong_product"
28
+ expired = "expired"
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class LicenseVerification:
33
+ valid: bool
34
+ status: LicenseStatus
35
+ message: str
36
+ claims: LicenseClaims | None = None
37
+
38
+
39
+ def verify_installed_license(product: str, now: datetime | None = None) -> LicenseVerification:
40
+ """Valida a licença instalada sem expor detalhes de armazenamento ou hardware ao programa."""
41
+ try:
42
+ blob = load_license()
43
+ except FileNotFoundError:
44
+ return _failure(LicenseStatus.license_not_found, "A licença não está instalada.")
45
+ except OSError as error:
46
+ return _failure(LicenseStatus.license_read_error, f"Não foi possível ler a licença: {error}.")
47
+ try:
48
+ machine_id = current_machine_id()
49
+ except MachineFingerprintError as error:
50
+ return _failure(LicenseStatus.machine_error, str(error))
51
+ return verify_license(blob, machine_id, product, now)
52
+
53
+
54
+ def verify_license(
55
+ blob: bytes,
56
+ expected_machine_id: str,
57
+ product: str,
58
+ now: datetime | None = None,
59
+ ) -> LicenseVerification:
60
+ """Valida assinatura, computador, produto e vencimento."""
61
+ try:
62
+ envelope = LicenseEnvelope.from_blob(blob)
63
+ except LicenseContractError as error:
64
+ return _failure(LicenseStatus.malformed, str(error))
65
+ try:
66
+ public_key = serialization.load_pem_public_key(PUBLIC_KEY_PEM)
67
+ except (TypeError, ValueError) as error:
68
+ return _failure(LicenseStatus.invalid_public_key, f"Não foi possível abrir a chave pública: {error}.")
69
+ if not isinstance(public_key, Ed25519PublicKey):
70
+ return _failure(LicenseStatus.invalid_public_key, "A chave pública não é Ed25519.")
71
+ try:
72
+ public_key.verify(envelope.signature, envelope.payload)
73
+ except InvalidSignature:
74
+ return _failure(LicenseStatus.invalid_signature, "A assinatura da licença é inválida.")
75
+ try:
76
+ claims = LicenseClaims.from_payload_bytes(envelope.payload)
77
+ except LicenseContractError as error:
78
+ return _failure(LicenseStatus.malformed, str(error))
79
+ if not hmac.compare_digest(claims.machine_id, expected_machine_id):
80
+ return _failure(LicenseStatus.wrong_machine, "A licença pertence a outro computador.", claims)
81
+ if not any(hmac.compare_digest(licensed_product, product) for licensed_product in claims.products):
82
+ return _failure(LicenseStatus.wrong_product, f"A licença não libera o produto {product}.", claims)
83
+ current_time = datetime.now(timezone.utc) if now is None else now
84
+ if current_time.tzinfo is None or current_time.utcoffset() is None:
85
+ raise ValueError("now deve possuir fuso horário.")
86
+ if current_time.astimezone(timezone.utc) > claims.expires_at:
87
+ return _failure(LicenseStatus.expired, "A licença está vencida.", claims)
88
+ return LicenseVerification(True, LicenseStatus.valid, "Licença válida.", claims)
89
+
90
+
91
+ def _failure(status: LicenseStatus, message: str, claims: LicenseClaims | None = None) -> LicenseVerification:
92
+ return LicenseVerification(False, status, message, claims)
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+
3
+ from setuptools import find_packages, setup
4
+
5
+
6
+ readme = (Path(__file__).parent / "README.md").read_text(encoding="utf-8")
7
+
8
+
9
+ setup(
10
+ name="cuboutils",
11
+ version="0.1.0",
12
+ author="Leonardo Pires Batista",
13
+ author_email="leonardopbatista98@gmail.com",
14
+ description="Utilitários compartilhados pelos produtos CUBO",
15
+ long_description=readme,
16
+ long_description_content_type="text/markdown",
17
+ keywords="cubo licença atualização",
18
+ packages=find_packages(exclude=("tests", "tests.*")),
19
+ python_requires=">=3.12",
20
+ install_requires=["cryptography==50.0.1"],
21
+ )
@@ -0,0 +1,91 @@
1
+ import base64
2
+ import json
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+ from unittest import TestCase, skipUnless
6
+ from unittest.mock import patch
7
+
8
+ from cryptography.hazmat.primitives import serialization
9
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
10
+
11
+ from license import LicenseClaims, LicenseStatus, issue_license, verify_installed_license, verify_license
12
+
13
+
14
+ MACHINE_ID = "HW1-" + "A" * 52
15
+ OTHER_MACHINE_ID = "HW1-" + "B" * 52
16
+ PRIVATE_KEY_PATH = Path(__file__).resolve().parents[1] / "private-key.pem"
17
+
18
+
19
+ def _instant(value: str) -> datetime:
20
+ return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
21
+
22
+
23
+ class LicenseFlowTests(TestCase):
24
+ def setUp(self) -> None:
25
+ private_key = Ed25519PrivateKey.generate()
26
+ private_key_pem = private_key.private_bytes(
27
+ encoding=serialization.Encoding.PEM,
28
+ format=serialization.PrivateFormat.PKCS8,
29
+ encryption_algorithm=serialization.NoEncryption(),
30
+ )
31
+ public_key_pem = private_key.public_key().public_bytes(
32
+ encoding=serialization.Encoding.PEM,
33
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
34
+ )
35
+ public_key_patch = patch("license.verifier.PUBLIC_KEY_PEM", public_key_pem)
36
+ public_key_patch.start()
37
+ self.addCleanup(public_key_patch.stop)
38
+ self.claims = LicenseClaims(MACHINE_ID, ("TQSVigas", "OutroProduto"), _instant("2027-09-04T23:59:59Z"))
39
+ self.blob = issue_license(self.claims, private_key_pem)
40
+
41
+ def test_issued_license_is_accepted_by_verifier(self) -> None:
42
+ first = verify_license(self.blob, MACHINE_ID, "TQSVigas", _instant("2026-09-20T12:00:00Z"))
43
+ second = verify_license(self.blob, MACHINE_ID, "OutroProduto", _instant("2026-09-20T12:00:00Z"))
44
+
45
+ self.assertTrue(first.valid)
46
+ self.assertTrue(second.valid)
47
+ self.assertEqual(first.claims.products, ("OutroProduto", "TQSVigas"))
48
+
49
+ def test_consumer_validates_the_installed_license_with_one_call(self) -> None:
50
+ with patch("license.verifier.load_license", return_value=self.blob), \
51
+ patch("license.verifier.current_machine_id", return_value=MACHINE_ID):
52
+ result = verify_installed_license("TQSVigas", _instant("2026-09-20T12:00:00Z"))
53
+
54
+ self.assertTrue(result.valid)
55
+
56
+ def test_reports_when_license_is_not_installed(self) -> None:
57
+ with patch("license.verifier.load_license", side_effect=FileNotFoundError):
58
+ result = verify_installed_license("TQSVigas")
59
+
60
+ self.assertEqual(result.status, LicenseStatus.license_not_found)
61
+
62
+ def test_rejects_tampered_license(self) -> None:
63
+ document = json.loads(self.blob)
64
+ payload = json.loads(base64.urlsafe_b64decode(document["payload"] + "=" * (-len(document["payload"]) % 4)))
65
+ payload["products"] = ["ProdutoAdulterado"]
66
+ changed = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
67
+ document["payload"] = base64.urlsafe_b64encode(changed).rstrip(b"=").decode()
68
+
69
+ result = verify_license(json.dumps(document).encode(), MACHINE_ID, "ProdutoAdulterado", _instant("2026-09-20T12:00:00Z"))
70
+
71
+ self.assertEqual(result.status, LicenseStatus.invalid_signature)
72
+
73
+ def test_rejects_wrong_machine_product_and_expiration(self) -> None:
74
+ wrong_machine = verify_license(self.blob, OTHER_MACHINE_ID, "TQSVigas", _instant("2026-09-20T12:00:00Z"))
75
+ wrong_product = verify_license(self.blob, MACHINE_ID, "ProdutoSemLicenca", _instant("2026-09-20T12:00:00Z"))
76
+ expired = verify_license(self.blob, MACHINE_ID, "TQSVigas", _instant("2027-09-05T12:00:00Z"))
77
+
78
+ self.assertEqual(wrong_machine.status, LicenseStatus.wrong_machine)
79
+ self.assertEqual(wrong_product.status, LicenseStatus.wrong_product)
80
+ self.assertEqual(expired.status, LicenseStatus.expired)
81
+
82
+
83
+ @skipUnless(PRIVATE_KEY_PATH.is_file(), "A chave privada local do protótipo não está disponível.")
84
+ class PrototypeKeyPairTests(TestCase):
85
+ def test_repository_private_key_matches_the_embedded_public_key(self) -> None:
86
+ claims = LicenseClaims(MACHINE_ID, ("TQSVigas",), _instant("2027-09-04T23:59:59Z"))
87
+
88
+ blob = issue_license(claims, PRIVATE_KEY_PATH.read_bytes())
89
+ result = verify_license(blob, MACHINE_ID, "TQSVigas", _instant("2026-09-20T12:00:00Z"))
90
+
91
+ self.assertTrue(result.valid)
@@ -0,0 +1,24 @@
1
+ from unittest import TestCase
2
+
3
+ from license import current_machine_id
4
+ from license.machine import MachineFingerprintError, fingerprint_from_identifiers
5
+
6
+
7
+ class MachineFingerprintTests(TestCase):
8
+ def test_is_deterministic_and_hides_source_values(self) -> None:
9
+ first = fingerprint_from_identifiers({"system_uuid": " ABC-123 ", "baseboard_serial": "Board-456"})
10
+ second = fingerprint_from_identifiers({"baseboard_serial": "board-456", "system_uuid": "abc-123"})
11
+
12
+ self.assertEqual(first, second)
13
+ self.assertRegex(first, r"^HW1-[A-Z2-7]{52}$")
14
+ self.assertNotIn("ABC", first)
15
+ self.assertNotIn("456", first)
16
+
17
+ def test_package_exports_the_machine_code(self) -> None:
18
+ from license.machine import current_machine_id as implementation
19
+
20
+ self.assertIs(current_machine_id, implementation)
21
+
22
+ def test_rejects_placeholder_identifiers(self) -> None:
23
+ with self.assertRaises(MachineFingerprintError):
24
+ fingerprint_from_identifiers({"system_uuid": "00000000-0000-0000-0000-000000000000", "serial": "FFFFFFFF"})
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+ from unittest import TestCase
3
+ from unittest.mock import patch
4
+
5
+ from license import save_license
6
+ from license.storage import LICENSE_PATH
7
+
8
+
9
+ class LicenseStorageTests(TestCase):
10
+ def test_uses_the_expected_path(self) -> None:
11
+ self.assertEqual(LICENSE_PATH.name, "-")
12
+ self.assertEqual(LICENSE_PATH.parent.name, "CUBO")
13
+
14
+ def test_saves_blob_and_applies_windows_attributes(self) -> None:
15
+ path = Path(r"C:\ProgramData\CUBO\-")
16
+ with patch("license.storage.LICENSE_PATH", path), patch.object(Path, "mkdir") as make_directory, \
17
+ patch.object(Path, "write_bytes") as write_bytes, patch("license.storage._set_hidden_and_system") as set_attributes:
18
+ saved_path = save_license(b"licenca")
19
+
20
+ self.assertEqual(saved_path, path)
21
+ make_directory.assert_called_once_with(parents=True, exist_ok=True)
22
+ write_bytes.assert_called_once_with(b"licenca")
23
+ set_attributes.assert_called_once_with(path)
@@ -0,0 +1,56 @@
1
+ import json
2
+ from unittest import TestCase
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from update_checker import UpdateCheckError, is_latest_version
6
+
7
+
8
+ class UpdateCheckerTests(TestCase):
9
+ def test_reports_the_latest_version(self) -> None:
10
+ response = self._response({"TQSVigas": "v2027.2.0", "TQSFormas": "v2027.1.0"})
11
+
12
+ with patch("update_checker.checker.urlopen", return_value=response) as open_endpoint:
13
+ result = is_latest_version("https://updates.example.com/versions.json", "TQSVigas", "2027.2")
14
+
15
+ self.assertTrue(result)
16
+ request = open_endpoint.call_args.args[0]
17
+ self.assertEqual(request.full_url, "https://updates.example.com/versions.json")
18
+
19
+ def test_reports_an_outdated_version(self) -> None:
20
+ response = self._response({"TQSVigas": "2027.2.0"})
21
+
22
+ with patch("update_checker.checker.urlopen", return_value=response):
23
+ result = is_latest_version("https://updates.example.com/versions.json", "TQSVigas", "2027.1.9")
24
+
25
+ self.assertFalse(result)
26
+
27
+ def test_accepts_a_version_newer_than_the_published_version(self) -> None:
28
+ response = self._response({"TQSVigas": "2027.2.0"})
29
+
30
+ with patch("update_checker.checker.urlopen", return_value=response):
31
+ result = is_latest_version("https://updates.example.com/versions.json", "TQSVigas", "2028.1.0")
32
+
33
+ self.assertTrue(result)
34
+
35
+ def test_rejects_an_insecure_endpoint(self) -> None:
36
+ with self.assertRaisesRegex(ValueError, "HTTPS"):
37
+ is_latest_version("http://updates.example.com/versions.json", "TQSVigas", "2027.2.0")
38
+
39
+ def test_reports_a_missing_product(self) -> None:
40
+ response = self._response({"OutroProduto": "2027.2.0"})
41
+
42
+ with patch("update_checker.checker.urlopen", return_value=response), self.assertRaises(UpdateCheckError):
43
+ is_latest_version("https://updates.example.com/versions.json", "TQSVigas", "2027.2.0")
44
+
45
+ def test_rejects_an_invalid_published_version(self) -> None:
46
+ response = self._response({"TQSVigas": "versão inválida"})
47
+
48
+ with patch("update_checker.checker.urlopen", return_value=response), self.assertRaises(UpdateCheckError):
49
+ is_latest_version("https://updates.example.com/versions.json", "TQSVigas", "2027.2.0")
50
+
51
+ @staticmethod
52
+ def _response(versions: dict[str, str]) -> MagicMock:
53
+ response = MagicMock()
54
+ response.read.return_value = json.dumps(versions).encode("utf-8")
55
+ response.__enter__.return_value = response
56
+ return response
@@ -0,0 +1,5 @@
1
+ """Consulta a versão mais recente dos produtos CUBO."""
2
+
3
+ from update_checker.checker import UpdateCheckError, is_latest_version
4
+
5
+ __all__ = ["UpdateCheckError", "is_latest_version"]
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from urllib.error import HTTPError, URLError
6
+ from urllib.parse import urlsplit
7
+ from urllib.request import Request, urlopen
8
+
9
+
10
+ _MAX_RESPONSE_SIZE = 4 * 1024
11
+ _VERSION_PATTERN = re.compile(r"v?\d+(?:\.\d+)*")
12
+
13
+
14
+ class UpdateCheckError(RuntimeError):
15
+ """Indica que não foi possível verificar a versão mais recente."""
16
+
17
+
18
+ def is_latest_version(endpoint: str, product: str, version: str, timeout: float = 5.0) -> bool:
19
+ """Informa se a versão instalada é igual ou posterior à versão publicada."""
20
+ if not isinstance(product, str) or not product:
21
+ raise ValueError("product deve ser uma string não vazia.")
22
+ current_version = _parse_version(version)
23
+ _validate_endpoint(endpoint)
24
+ request = Request(endpoint, headers={"Accept": "application/json"})
25
+
26
+ try:
27
+ with urlopen(request, timeout=timeout) as response:
28
+ content = response.read(_MAX_RESPONSE_SIZE + 1)
29
+ except (HTTPError, URLError, OSError) as error:
30
+ raise UpdateCheckError(f"Não foi possível consultar a versão de {product}.") from error
31
+
32
+ if len(content) > _MAX_RESPONSE_SIZE:
33
+ raise UpdateCheckError("A resposta do servidor excede o tamanho permitido.")
34
+ try:
35
+ document = json.loads(content.decode("utf-8"))
36
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
37
+ raise UpdateCheckError("O servidor não retornou um JSON UTF-8 válido.") from error
38
+
39
+ if not isinstance(document, dict):
40
+ raise UpdateCheckError("A resposta do servidor deve ser um objeto JSON.")
41
+ if product not in document:
42
+ raise UpdateCheckError(f"O produto {product} não foi encontrado na resposta do servidor.")
43
+ latest_version = document[product]
44
+ if not isinstance(latest_version, str):
45
+ raise UpdateCheckError(f"O servidor não retornou uma versão válida para {product}.")
46
+ try:
47
+ published_version = _parse_version(latest_version)
48
+ except ValueError as error:
49
+ raise UpdateCheckError("O servidor retornou uma versão inválida.") from error
50
+
51
+ return current_version >= published_version
52
+
53
+
54
+ def _validate_endpoint(endpoint: str) -> None:
55
+ if not isinstance(endpoint, str):
56
+ raise TypeError("endpoint deve ser uma string.")
57
+ parts = urlsplit(endpoint)
58
+ if parts.scheme.casefold() != "https" or not parts.netloc:
59
+ raise ValueError("endpoint deve ser uma URL HTTPS válida.")
60
+
61
+
62
+ def _parse_version(value: str) -> tuple[int, ...]:
63
+ if not isinstance(value, str) or not _VERSION_PATTERN.fullmatch(value):
64
+ raise ValueError("A versão deve conter números separados por pontos e pode começar com v.")
65
+ parts = [int(part) for part in value.removeprefix("v").split(".")]
66
+ while len(parts) > 1 and parts[-1] == 0:
67
+ parts.pop()
68
+ return tuple(parts)