Updated on 2026-08-14
This commit is contained in:
parent
8a83664e9a
commit
728187e890
11 changed files with 231 additions and 139 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import java.io.ByteArrayOutputStream
|
||||
import java.security.MessageDigest
|
||||
|
||||
plugins {
|
||||
|
|
@ -61,15 +62,15 @@ abstract class VerifyDesignTokensTask : DefaultTask() {
|
|||
|
||||
require(actual == expected) {
|
||||
"Design tokens are out of date!\n" +
|
||||
" ds-tokens hash: $actual\n" +
|
||||
" generated hash: $expected\n" +
|
||||
" computed from ds-tokens: $actual\n" +
|
||||
" committed .tokens-hash: $expected\n" +
|
||||
"Run the token generator: cd core/ui/token-gen && npm run build"
|
||||
}
|
||||
|
||||
stampFile.get().asFile.writeText(actual)
|
||||
}
|
||||
|
||||
private fun hashTreeHex(root: java.io.File, extension: String): String {
|
||||
private fun hashTreeHex(root: File, extension: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val files = root.walkTopDown()
|
||||
.filter { it.isFile && it.extension == extension }
|
||||
|
|
@ -79,12 +80,27 @@ abstract class VerifyDesignTokensTask : DefaultTask() {
|
|||
for (file in files) {
|
||||
digest.update(file.relativeTo(root).invariantSeparatorsPath.toByteArray())
|
||||
digest.update(nul)
|
||||
digest.update(file.readBytes())
|
||||
digest.update(file.readBytes().stripCr())
|
||||
digest.update(nul)
|
||||
}
|
||||
return digest.digest()
|
||||
.joinToString("") { b: Byte -> b.toInt().and(0xFF).toString(16).padStart(2, '0') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips CR (0x0D) bytes so the token hash ignores CRLF vs LF line endings. The ds-tokens
|
||||
* submodule is not covered by this repo's .gitattributes, so its .json/.svg sources may be
|
||||
* checked out with CRLF on some platforms; without this the verification is non-deterministic.
|
||||
* Removes lone CRs too — safe for UTF-8 sources, where 0x0D never appears inside a
|
||||
* multi-byte sequence. Must stay byte-for-byte identical to stripCr() in token-gen/hash-util.mjs.
|
||||
*/
|
||||
private fun ByteArray.stripCr(): ByteArray {
|
||||
val cr: Byte = 0x0D
|
||||
if (none { it == cr }) return this
|
||||
val out = ByteArrayOutputStream(size)
|
||||
for (b in this) if (b != cr) out.write(b.toInt())
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
android {
|
||||
namespace = "com.tangem.core.ui"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import crypto from 'crypto';
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { stripCr, compareCodeUnits } from './hash-util.mjs';
|
||||
|
||||
// ── Paths ──────────────────────────────────────────────────────────────────────
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -252,16 +253,17 @@ object Icons
|
|||
|
||||
function computeIconsHash() {
|
||||
const files = [...walkSvgs(iconsDir)];
|
||||
files.sort((a, b) => {
|
||||
const ra = path.relative(iconsDir, a).split(path.sep).join('/');
|
||||
const rb = path.relative(iconsDir, b).split(path.sep).join('/');
|
||||
return ra.localeCompare(rb);
|
||||
});
|
||||
// Code-unit order (not localeCompare) to match the Kotlin verifier's invariantSeparatorsPath
|
||||
// sort deterministically across locales/ICU versions — see hash-util.mjs.
|
||||
files.sort((a, b) => compareCodeUnits(
|
||||
path.relative(iconsDir, a).split(path.sep).join('/'),
|
||||
path.relative(iconsDir, b).split(path.sep).join('/'),
|
||||
));
|
||||
const hash = crypto.createHash('sha256');
|
||||
for (const file of files) {
|
||||
hash.update(path.relative(iconsDir, file).split(path.sep).join('/'));
|
||||
hash.update('\0');
|
||||
hash.update(fs.readFileSync(file));
|
||||
hash.update(stripCr(fs.readFileSync(file)));
|
||||
hash.update('\0');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { buildIcons } from './build-icons.mjs';
|
||||
import { stripCr, compareCodeUnits } from './hash-util.mjs';
|
||||
|
||||
// ── Paths ──────────────────────────────────────────────────────────────────────
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -792,18 +793,18 @@ function computeTokensHash() {
|
|||
}
|
||||
}
|
||||
walk(tokensDir);
|
||||
// Sort by relative path with forward slashes to match Gradle's invariantSeparatorsPath sorting
|
||||
files.sort((a, b) => {
|
||||
const ra = path.relative(tokensDir, a).split(path.sep).join('/');
|
||||
const rb = path.relative(tokensDir, b).split(path.sep).join('/');
|
||||
return ra.localeCompare(rb);
|
||||
});
|
||||
// Sort by forward-slash relative path in UTF-16 code-unit order to match the Kotlin verifier's
|
||||
// sortedBy { …invariantSeparatorsPath } (String.compareTo) deterministically — see hash-util.mjs.
|
||||
files.sort((a, b) => compareCodeUnits(
|
||||
path.relative(tokensDir, a).split(path.sep).join('/'),
|
||||
path.relative(tokensDir, b).split(path.sep).join('/'),
|
||||
));
|
||||
|
||||
const hash = crypto.createHash('sha256');
|
||||
for (const file of files) {
|
||||
hash.update(path.relative(tokensDir, file).split(path.sep).join('/'));
|
||||
hash.update('\0');
|
||||
hash.update(fs.readFileSync(file));
|
||||
hash.update(stripCr(fs.readFileSync(file)));
|
||||
hash.update('\0');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
|
|
|
|||
31
core/ui/token-gen/hash-util.mjs
Normal file
31
core/ui/token-gen/hash-util.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Shared hashing helpers for the design-token generators (build-tokens.mjs / build-icons.mjs).
|
||||
// Kept in one place because the exact byte stream AND file ordering produced here must stay
|
||||
// identical to the Kotlin verifier in core/ui/build.gradle.kts (VerifyDesignTokensTask):
|
||||
// any drift silently breaks the token hash gate.
|
||||
|
||||
/**
|
||||
* Strip every CR (0x0D) byte so the hash ignores CRLF vs LF line endings. The ds-tokens submodule
|
||||
* is not covered by the parent repo's .gitattributes, so its .json/.svg sources may be checked out
|
||||
* with CRLF on some platforms. Lone CRs are dropped too — safe here because these sources are UTF-8
|
||||
* (0x0D never appears inside a multi-byte sequence) and carry no bare CR.
|
||||
* Must stay byte-for-byte identical to ByteArray.stripCr() in core/ui/build.gradle.kts.
|
||||
*/
|
||||
export function stripCr(buf) {
|
||||
const out = Buffer.allocUnsafe(buf.length);
|
||||
let n = 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf[i] !== 0x0d) out[n++] = buf[i];
|
||||
}
|
||||
return out.subarray(0, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two forward-slash relative paths by UTF-16 code unit — identical to Kotlin's
|
||||
* String.compareTo used by `sortedBy { …invariantSeparatorsPath }` in the verifier.
|
||||
* Do NOT use String.prototype.localeCompare for file ordering: it is locale/ICU-version dependent
|
||||
* (differs across machines) and case-insensitive at the primary level (disagrees with the Kotlin
|
||||
* code-unit sort) — either would reorder files and change the hash.
|
||||
*/
|
||||
export function compareCodeUnits(a, b) {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue