Upstream spencermountain/spacetime state of the touched files before fe90cae667

This commit is contained in:
upstream committed 2026-10-02 16:02:11 +02:00
1 parent 081c965030
commit d47824c8f3
6 files changed
+2406

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
// CLI orchestration: read zoneinfo, normalize records, report differences, then write.
import { execFileSync } from 'node:child_process'
import {
existsSync,
readFileSync,
readdirSync,
renameSync,
statSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import data from '../../zonefile/iana.js'
import aliases from '../../zonefile/aliases.js'
import { parseIntervals, normalizeZone } from './lib.js'
import { printHeader, printReport, printResult, printError, printHelp } from './report.js'
const root = fileURLToPath(new URL('../../', import.meta.url))
// Directory-only legacy keys may be retained with --allow-unsupported.
// Missing or malformed files remain fatal because their timezone state is unknown.
export class UnsupportedZoneError extends Error {}
export const parseArgs = (args) => {
const options = {
year: new Date().getUTCFullYear(),
zoneinfoDir: '/usr/share/zoneinfo',
check: false,
verbose: false,
allowUnsupported: false
}
for (let i = 0; i < args.length; i += 1) {
const arg = args[i]
if (arg === '--check') {
options.check = true
} else if (arg === '--verbose') {
options.verbose = true
} else if (arg === '--allow-unsupported') {
options.allowUnsupported = true
} else if (arg === '--help') {
options.help = true
} else if (['--year', '--zoneinfo-dir', '--output'].includes(arg)) {
const value = args[++i]
if (!value || value.startsWith('--')) {
throw new Error('Missing value for ' + arg)
}
if (arg === '--year') {
if (!/^\d{4}$/.test(value) || Number(value) < 1900 || Number(value) > 9998) {
throw new Error('Year must be 1900–9998')
}
options.year = Number(value)
} else {
options[arg === '--output' ? 'output' : 'zoneinfoDir'] = resolve(value)
}
} else {
throw new Error('Unknown option: ' + arg)
}
}
options.output ||= join(root, `zonefile.${options.year}.js`)
return options
}
// Library keys are lowercase; zoneinfo paths are case-sensitive on many systems.
// Apply known aliases, then resolve every component using its actual spelling.
export const resolveZone = (directory, name) => {
name = aliases[name] || name
let path = directory
for (const component of name.split('/')) {
if (!component || component === '.' || component === '..') {
throw new Error('Invalid zone name')
}
const matches = readdirSync(path).filter(
(entry) => entry.toLowerCase() === component.toLowerCase()
)
if (matches.length !== 1) {
throw new Error('Missing or ambiguous zone: ' + name)
}
path = join(path, matches[0])
}
const stats = statSync(path)
// zdump can silently report UTC for a directory. Reject it before invocation.
if (stats.isDirectory()) {
throw Object.assign(new UnsupportedZoneError('Zone name resolves to a directory, not a timezone: ' + name), {
details: [
`Resolved path: ${path}`,
`Available entries: ${readdirSync(path).sort().join(', ')}`,
'Choose a specific timezone or define an explicit library alias; a directory has no offset or transitions.'
]
})
}
if (!stats.isFile() || readFileSync(path).subarray(0, 4).toString() !== 'TZif') {
throw new Error('Not a compiled TZif file: ' + name)
}
return path
}
// macOS provides +VERSION; common Linux distributions embed it in tzdata.zi.
// File modification times are not reliable tzdata release identifiers.
const sourceVersion = (directory) => {
for (const name of ['+VERSION', 'tzdata.zi']) {
const path = join(directory, name)
if (existsSync(path)) {
const text = readFileSync(path, 'utf8')
const version = name === '+VERSION' ? text.trim() : /^# version (\S+)/m.exec(text)?.[1]
if (version) {
return version
}
}
}
return 'unknown'
}
// Inject the reader so callers can supply fixtures without invoking zdump.
// Build a fresh result and retain diagnostic context without mutating source data.
export const updateZones = (zones, year, readZone) => {
const result = {}
const changes = []
const unsupported = []
for (const [name, previous] of Object.entries(zones)) {
// Acquisition and parsing errors must fail, even with --allow-unsupported.
let intervals
try {
intervals = parseIntervals(readZone(name))
} catch (error) {
if (error instanceof UnsupportedZoneError) {
unsupported.push({ name, reason: error.message, previous, details: error.details })
result[name] = { ...previous }
continue
}
throw new Error(`${name}: ${error.message}`)
}
try {
result[name] = normalizeZone(intervals, previous, name, year)
} catch (error) {
unsupported.push({ name, reason: error.message, previous, intervals, details: error.details })
result[name] = { ...previous }
continue
}
if (JSON.stringify(previous) !== JSON.stringify(result[name])) {
changes.push({ name, before: previous, after: result[name] })
}
}
return { result, changes, unsupported }
}
export const main = (args = process.argv.slice(2)) => {
const options = parseArgs(args)
if (options.help) {
printHelp()
return
}
const { year, zoneinfoDir } = options
const version = sourceVersion(zoneinfoDir)
const total = Object.keys(data).length
printHeader({ ...options, version, total })
const { result, changes, unsupported } = updateZones(data, year, (name) => {
const path = resolveZone(zoneinfoDir, name)
// Bound extraction to one UTC year; argument arrays avoid shell interpretation.
// Interval output includes an initial offset even for zones with no transitions.
return execFileSync('zdump', ['-i', '-c', `${year},${year + 1}`, path], {
encoding: 'utf8',
timeout: 10000,
maxBuffer: 1024 * 1024,
env: { ...process.env, LC_ALL: 'C' },
stdio: ['ignore', 'pipe', 'pipe']
})
})
printReport({ changes, unsupported, total, verbose: options.verbose })
// Check mode and the unsupported-zone gate must run before any file write.
if (options.check) {
if (changes.length > 0 || unsupported.length > 0) {
process.exitCode = 1
}
printResult(
changes.length > 0 || unsupported.length > 0 ? 'Check needs attention. No files written.' : 'Check passed. No files written.',
Boolean(changes.length > 0 || unsupported.length > 0)
)
return
}
if (unsupported.length > 0 && !options.allowUnsupported) {
throw new Error(
'No output written. Resolve unsupported zones or explicitly preserve them with --allow-unsupported.'
)
}
const source = JSON.stringify({
year,
tzdata: version,
unsupported: unsupported.map((o) => o.name)
})
const text = `// Generated by scripts/tz/update.js: ${source}\nexport default ${JSON.stringify(result, null, 2)}\n`
// Rename within the destination directory so readers never see a partial file.
// Only remove a temporary file created by this run (exclusive creation may fail).
const temporary = join(dirname(options.output), `.zonefile-${process.pid}.tmp`)
let created = false
try {
writeFileSync(temporary, text, { encoding: 'utf8', flag: 'wx' })
created = true
renameSync(temporary, options.output)
} finally {
if (created && existsSync(temporary)) {
unlinkSync(temporary)
}
}
printResult(`Wrote ${options.output}`)
}
try {
main()
} catch (error) {
printError(error.message)
process.exitCode = 1
}