diff --git a/src/calculator.ts b/src/calculator.ts index 68b894d..c8e2168 100644 --- a/src/calculator.ts +++ b/src/calculator.ts @@ -15,7 +15,7 @@ export function multiply(a: number, b: number): number { return a * b } -// BUG: Division by zero is not handled export function divide(a: number, b: number): number { + if (b === 0) throw new RangeError("Division by zero") return a / b } diff --git a/src/date-utils.ts b/src/date-utils.ts index 37272a7..c4a817c 100644 --- a/src/date-utils.ts +++ b/src/date-utils.ts @@ -5,16 +5,13 @@ /** * Format a date as a human-readable relative string. * e.g. "2 days ago", "just now", "in 3 hours" - * - * BUG: off-by-one — uses Math.floor where Math.round is needed for days, - * causing "1 day ago" to appear for anything from 12h to 47h. */ export function formatRelative(date: Date, now: Date = new Date()): string { const diffMs = now.getTime() - date.getTime() const diffSec = diffMs / 1000 const diffMin = diffSec / 60 const diffHours = diffMin / 60 - const diffDays = Math.floor(diffHours / 24) // BUG: should be Math.round + const diffDays = Math.round(Math.abs(diffHours) / 24) if (Math.abs(diffSec) < 60) return "just now" if (Math.abs(diffMin) < 60) { @@ -25,7 +22,7 @@ export function formatRelative(date: Date, now: Date = new Date()): string { const h = Math.round(Math.abs(diffHours)) return diffMs > 0 ? `${h} hour${h !== 1 ? "s" : ""} ago` : `in ${h} hour${h !== 1 ? "s" : ""}` } - const d = Math.abs(diffDays) + const d = diffDays return diffMs > 0 ? `${d} day${d !== 1 ? "s" : ""} ago` : `in ${d} day${d !== 1 ? "s" : ""}` } diff --git a/src/string-utils.ts b/src/string-utils.ts index 63fba18..f56ef79 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -11,10 +11,31 @@ export function reverse(str: string): string { return str.split("").reverse().join("") } -// TODO: implement truncate — should truncate at a word boundary, with "..." -// counting toward maxLength. Return unchanged if str.length <= maxLength. +const ELLIPSIS = "..." + +/** + * Truncate at a word boundary, with ELLIPSIS counting toward maxLength. + * Returns str unchanged if str.length <= maxLength. The returned string is + * never longer than maxLength. + * + * Note: lengths are UTF-16 code units, so a maxLength that falls inside an + * astral character cuts before it rather than splitting the surrogate pair. + */ export function truncate(str: string, maxLength: number): string { - throw new Error("not implemented") + if (!Number.isFinite(maxLength) || maxLength < 0) { + throw new RangeError("maxLength must be a non-negative finite number") + } + if (str.length <= maxLength) return str + if (maxLength <= ELLIPSIS.length) return str.slice(0, maxLength) + + const budget = maxLength - ELLIPSIS.length + const head = str.slice(0, budget) + + if (/\s/.test(str.charAt(budget))) return head.trimEnd() + ELLIPSIS + + const boundary = head.search(/\s+\S*$/) + if (boundary === -1) return head + ELLIPSIS + return head.slice(0, boundary) + ELLIPSIS } export function slugify(str: string): string { @@ -24,8 +45,8 @@ export function slugify(str: string): string { .replace(/^-|-$/g, "") } -// BUG: This doesn't handle multiple consecutive spaces export function wordCount(str: string): number { - if (!str.trim()) return 0 - return str.split(" ").length + const trimmed = str.trim() + if (!trimmed) return 0 + return trimmed.split(/\s+/).length } diff --git a/src/task-manager.ts b/src/task-manager.ts index a920e85..cf80f7e 100644 --- a/src/task-manager.ts +++ b/src/task-manager.ts @@ -52,20 +52,32 @@ export class TaskManager { return true } - // TODO: implement — remove a task by id, return true if removed, false if not found remove(id: string): boolean { - throw new Error("not implemented") + return this.tasks.delete(id) } - // TODO: implement — update title/description/priority of a task - // return true if updated, false if not found update(id: string, changes: Partial>): boolean { - throw new Error("not implemented") + const task = this.tasks.get(id) + if (!task) return false + if (changes.title !== undefined) task.title = changes.title + if (changes.description !== undefined) task.description = changes.description + if (changes.priority !== undefined) task.priority = changes.priority + return true } - // TODO: implement — return all tasks sorted by the given field - // priority sort order: high > medium > low sortBy(field: "priority" | "createdAt" | "status"): Task[] { - throw new Error("not implemented") + const priorityRank: Record = { high: 0, medium: 1, low: 2 } + const statusRank: Record = { in_progress: 0, pending: 1, completed: 2 } + + return Array.from(this.tasks.values()).sort((a, b) => { + switch (field) { + case "priority": + return priorityRank[a.priority] - priorityRank[b.priority] + case "createdAt": + return a.createdAt.getTime() - b.createdAt.getTime() + case "status": + return statusRank[a.status] - statusRank[b.status] + } + }) } } diff --git a/src/validator.ts b/src/validator.ts index 27bf385..4058eb2 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -2,27 +2,67 @@ * Input validation utilities. */ +const LOCAL_ATOM = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+" +const DOMAIN_LABEL = "[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?" + +const EMAIL_RE = new RegExp( + "^" + + LOCAL_ATOM + + "(?:\\." + + LOCAL_ATOM + + ")*" + + "@" + + DOMAIN_LABEL + + "(?:\\." + + DOMAIN_LABEL + + ")*" + + "\\.[A-Za-z]{2,63}$", +) + /** - * Returns true if the string is a valid email address. + * Returns true if the string is a syntactically valid email address. + * Supports subdomains and TLDs of any valid length (e.g. .museum, .travel), + * and caps length at 254 characters per RFC 5321. * - * BUG: the regex does not allow subdomains (e.g. user@mail.example.com fails) - * and rejects valid TLDs longer than 4 chars (e.g. .museum, .travel). + * Local-part dot placement is validated per RFC 5322: leading, trailing and + * consecutive dots are rejected. Quoted local parts ("a..b"@example.com) and + * IP-literal domains (user@[192.0.2.1]) are not supported. + * + * NOTE: syntax only — this proves neither deliverability nor that the + * submitter controls the address. Gate registration and notification flows + * on a confirmation link. An email address is personal information: collect + * only where reasonably necessary (APP 3), give a collection notice (APP 5), + * and secure it in transit and at rest (APP 11). */ export function isEmail(value: string): boolean { - // BUG: too restrictive — missing subdomain support and long TLDs - return /^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,4}$/.test(value) + if (value.length > 254) return false + return EMAIL_RE.test(value) } /** - * Returns true if the string is a valid URL (http or https). + * Returns true if the string is a syntactically valid http/https URL. + * Ports are permitted (e.g. http://localhost:3000). + * + * SECURITY: this is a syntax and scheme check ONLY — it is not an SSRF + * control. It deliberately accepts hosts that are unsafe as server-side + * request targets, including: + * - loopback: http://127.0.0.1:22, http://[::1]:8080 + * - link-local / cloud metadata: http://169.254.169.254/ + * - private ranges (RFC1918): http://10.0.0.1/ + * - obfuscated IP literals: http://0177.0.0.1/, http://2130706433/ + * - userinfo confusion, where the real host is not the visible one: + * https://good.com@evil.com * - * BUG: rejects URLs with ports (e.g. http://localhost:3000) + * Before passing a user-supplied URL to an outbound request or a redirect, + * the caller must additionally enforce a host allowlist (not a denylist), + * reject url.username/url.password, normalise IP literals, and re-validate + * after DNS resolution to close DNS rebinding. See CWE-918, CWE-601, + * OWASP A10:2021, ISM-1240. */ export function isUrl(value: string): boolean { try { const url = new URL(value) - // BUG: only allows http/https but also rejects valid port usage - return (url.protocol === "http:" || url.protocol === "https:") && url.port === "" + return url.protocol === "http:" || url.protocol === "https:" } catch { return false }