Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions web/packages/common/src/utils/entityName.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import {
ENTITY_NAME_MAX_LENGTH,
ENTITY_NAME_REGEXP,
entityNameSchema,
getEntityNameError,
sanitizeEntityName,
toValidEntityName,
} from '@nemo/common/src/utils/entityName';
import { entitiesCreateEntityBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/entity-store';
import {
filesCreateFilesetBodyNameMax,
filesCreateFilesetBodyNameRegExp,
} from '@nemo/sdk/generated/platform/zod/files';
import { modelsCreateProviderBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/model-providers';
import {
secretsCreateSecretBodyNameMax,
secretsCreateSecretBodyNameRegExp,
} from '@nemo/sdk/generated/platform/zod/secrets';

describe('generated schema agreement', () => {
it.each([
['entity', entitiesCreateEntityBodyNameRegExp],
['fileset', filesCreateFilesetBodyNameRegExp],
['secret', secretsCreateSecretBodyNameRegExp],
['model provider', modelsCreateProviderBodyNameRegExp],
])('%s create schema uses the same name pattern', (_name, pattern) => {
expect(pattern.source).toBe(ENTITY_NAME_REGEXP.source);
});

it.each([
['fileset', filesCreateFilesetBodyNameMax],
['secret', secretsCreateSecretBodyNameMax],
])('%s create schema agrees on the max length', (_name, max) => {
expect(max).toBe(ENTITY_NAME_MAX_LENGTH);
});
});

describe('getEntityNameError', () => {
it.each(['sparl', 'a1', 'my-provider', 'llama-3.2-3b-instruct@v1.0.0+A100'.toLowerCase(), 'a_b'])(
'accepts %s',
(value) => {
expect(getEntityNameError(value)).toBeUndefined();
}
);

it('reports a missing value', () => {
expect(getEntityNameError('')).toBe('Name is required.');
});

it('reports uppercase with a lowercased suggestion', () => {
expect(getEntityNameError('Sparl')).toBe('Name must be lowercase. Try "sparl".');
});

it('lists the disallowed characters', () => {
expect(getEntityNameError('invalid name!')).toContain('cannot contain spaces, "!"');
});

it('reports a leading non-letter', () => {
expect(getEntityNameError('1provider')).toBe(
'Name must start with a lowercase letter. Try "provider".'
);
});

it('reports consecutive hyphens', () => {
expect(getEntityNameError('my--provider')).toBe(
'Name cannot contain consecutive hyphens. Try "my-provider".'
);
});

it('reports a trailing hyphen', () => {
expect(getEntityNameError('myprovider-')).toBe(
'Name cannot end with a hyphen. Try "myprovider".'
);
});

it('reports too-short values without a bogus suggestion', () => {
expect(getEntityNameError('a')).toBe('Name must be at least 2 characters.');
});

it('reports too-long values with the current length', () => {
expect(getEntityNameError('a'.repeat(64))).toContain(
'Name must be 63 characters or fewer (currently 64).'
);
});

it('uses the supplied label', () => {
expect(getEntityNameError('', 'Provider name')).toBe('Provider name is required.');
});
});

describe('sanitizeEntityName', () => {
it.each([
'Qwen3.6-35B-A3B-MTP-GGUF',
'mistralai/Mistral-7B-Instruct-v0.3',
'hello world',
' leading-trailing ',
'123-starts-with-digit',
'has--double--dashes',
'ends-with-dash-',
'x'.repeat(200),
])('produces a valid name for %s', (input) => {
const result = sanitizeEntityName(input);
expect(result).toBeDefined();
expect(ENTITY_NAME_REGEXP.test(result as string)).toBe(true);
});

it('returns undefined when nothing valid remains', () => {
expect(sanitizeEntityName('!!!')).toBeUndefined();
expect(sanitizeEntityName('')).toBeUndefined();
});
});

describe('toValidEntityName', () => {
it('falls back when nothing valid remains', () => {
expect(toValidEntityName('!!!', 'provider')).toBe('provider');
});
});

describe('entityNameSchema', () => {
it('surfaces the rule-specific message', () => {
const result = entityNameSchema('Provider name').safeParse('Sparl');
expect(result.success).toBe(false);
expect(result.success === false && result.error.issues[0].message).toBe(
'Provider name must be lowercase. Try "sparl".'
);
});

it('passes valid names through', () => {
expect(entityNameSchema().safeParse('sparl')).toMatchObject({ success: true, data: 'sparl' });
});
});
90 changes: 90 additions & 0 deletions web/packages/common/src/utils/entityName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { entitiesCreateEntityBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/entity-store';
import { z } from 'zod';

export const ENTITY_NAME_REGEXP = entitiesCreateEntityBodyNameRegExp;

export const ENTITY_NAME_MIN_LENGTH = 2;
// Entity-store schema declares no maxLength; the test pins this against ones that do.
export const ENTITY_NAME_MAX_LENGTH = 63;

export const ENTITY_NAME_HELP =
'Must start with a lowercase letter and be 2–63 characters. Lowercase letters, numbers, and - _ . @ + only. No consecutive or trailing hyphens.';

const INVALID_BODY = /[^a-z0-9\-@.+_]+/g;
const INVALID_BODY_CHAR = /[^a-z0-9\-@.+_]/g;
const COLLAPSE_DASHES = /-{2,}/g;
const STRIP_LEADING_NON_LETTER = /^[^a-z]+/;
const STRIP_TRAILING_DASH = /-+$/;

/** Rewrite input to satisfy `ENTITY_NAME_REGEXP`, or `undefined` if nothing valid remains. */
export function sanitizeEntityName(input: string): string | undefined {
const sanitized = input
.trim()
.toLowerCase()
.replace(INVALID_BODY, '-')
.replace(COLLAPSE_DASHES, '-')
.replace(STRIP_LEADING_NON_LETTER, '')
.replace(STRIP_TRAILING_DASH, '')
.slice(0, ENTITY_NAME_MAX_LENGTH)
.replace(STRIP_TRAILING_DASH, '');

return sanitized.length >= ENTITY_NAME_MIN_LENGTH ? sanitized : undefined;
}

/** Rewrite input to satisfy `ENTITY_NAME_REGEXP`, falling back when nothing valid remains. */
export function toValidEntityName(input: string, fallback: string): string {
return sanitizeEntityName(input) ?? fallback;
}

function listInvalidChars(value: string): string[] {
const found = value.replace(/[A-Z]/g, '').match(INVALID_BODY_CHAR) ?? [];
return [...new Set(found)].map((char) => (char === ' ' ? 'spaces' : `"${char}"`));
}

/** First rule the value breaks, phrased for a form field, or `undefined` if valid. */
export function getEntityNameError(value: string, label = 'Name'): string | undefined {
if (!value) return `${label} is required.`;
if (ENTITY_NAME_REGEXP.test(value)) return undefined;

const suggestion = sanitizeEntityName(value);
const hint = suggestion && suggestion !== value ? ` Try "${suggestion}".` : '';

if (value.length > ENTITY_NAME_MAX_LENGTH) {
return `${label} must be ${ENTITY_NAME_MAX_LENGTH} characters or fewer (currently ${value.length}).${hint}`;
}
if (value.length < ENTITY_NAME_MIN_LENGTH) {
return `${label} must be at least ${ENTITY_NAME_MIN_LENGTH} characters.`;
}
if (/[A-Z]/.test(value)) {
return `${label} must be lowercase.${hint}`;
}

const invalidChars = listInvalidChars(value);
if (invalidChars.length > 0) {
return `${label} cannot contain ${invalidChars.join(', ')}. Use lowercase letters, numbers, and - _ . @ + only.${hint}`;
}
if (!/^[a-z]/.test(value)) {
return `${label} must start with a lowercase letter.${hint}`;
}
if (value.includes('--')) {
return `${label} cannot contain consecutive hyphens.${hint}`;
}
if (value.endsWith('-')) {
return `${label} cannot end with a hyphen.${hint}`;
}

return `${label} is invalid. ${ENTITY_NAME_HELP}${hint}`;
}

/** Zod string schema enforcing `ENTITY_NAME_REGEXP` with per-rule error messages. */
export function entityNameSchema(label = 'Name'): z.ZodEffects<z.ZodString, string, string> {
return z.string().superRefine((value, ctx) => {
const message = getEntityNameError(value, label);
if (message) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message });
}
});
}
11 changes: 4 additions & 7 deletions web/packages/common/src/utils/filesetName.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import {
FILESET_NAME_MAX_LENGTH,
FILESET_NAME_REGEXP,
toValidFilesetName,
} from '@nemo/common/src/utils/filesetName';
import { ENTITY_NAME_REGEXP } from '@nemo/common/src/utils/entityName';
import { FILESET_NAME_MAX_LENGTH, toValidFilesetName } from '@nemo/common/src/utils/filesetName';

describe('toValidFilesetName', () => {
describe('produces output that satisfies FILESET_NAME_REGEXP', () => {
describe('produces output that satisfies ENTITY_NAME_REGEXP', () => {
const inputs = [
'Qwen3.6-35B-A3B-MTP-GGUF', // HF slug with uppercase
'mistralai/Mistral-7B-Instruct-v0.3',
Expand All @@ -25,7 +22,7 @@ describe('toValidFilesetName', () => {
];
it.each(inputs)('%s -> matches regex', (input) => {
const out = toValidFilesetName(input);
expect(out).toMatch(FILESET_NAME_REGEXP);
expect(out).toMatch(ENTITY_NAME_REGEXP);
});
});

Expand Down
53 changes: 4 additions & 49 deletions web/packages/common/src/utils/filesetName.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,14 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* Mirrors the entity store's RFC-1035-ish name pattern from
* `packages/nmp_common/src/nmp/common/entities/constants.py` (`NAME_PATTERN`):
*
* ^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(?<!-)$
*
* - 2-63 chars total
* - First char must be a lowercase letter
* - Body chars: lowercase letters, digits, `-`, `@`, `.`, `+`, `_`
* - No consecutive `--`
* - No trailing `-`
*
* The Files service's `CreateFilesetRequest` DTO advertises a looser pattern
* (`^[\w\-.]+$`, max 255), which is what OpenAPI/orval pulls into zod. The
* stricter pattern is only enforced downstream by the entity store, so the
* generated SDK and a naive sanitizer let invalid names through to a confusing
* 422 at write time.
*/

/** Source-of-truth regex for fileset names. */
export const FILESET_NAME_REGEXP = /^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(?<!-)$/;
import { ENTITY_NAME_MAX_LENGTH, toValidEntityName } from '@nemo/common/src/utils/entityName';

/** Max length (first char + 62) per the entity store pattern. */
export const FILESET_NAME_MAX_LENGTH = 63;
export const FILESET_NAME_MAX_LENGTH = ENTITY_NAME_MAX_LENGTH;

/** Fallback when the input sanitizes down to nothing valid. */
const FALLBACK = 'fileset';

const INVALID_BODY = /[^a-z0-9\-@.+_]+/g;
const COLLAPSE_DASHES = /-{2,}/g;
const STRIP_LEADING_NON_LETTER = /^[^a-z]+/;
const STRIP_TRAILING_DASH = /-+$/;

/**
* Rewrite any input into a value that satisfies `FILESET_NAME_REGEXP`.
*
* Strategy: lowercase, replace disallowed body chars with `-`, collapse `--`,
* strip leading non-letter chars (first char must be `[a-z]`), strip trailing
* `-`, truncate to 63 chars, then strip any newly-exposed trailing `-`.
* Falls back to `"fileset"` when nothing valid remains.
*/
/** Rewrite any input into a value that satisfies `ENTITY_NAME_REGEXP`. */
export function toValidFilesetName(input: string): string {
let s = input
.trim()
.toLowerCase()
.replace(INVALID_BODY, '-')
.replace(COLLAPSE_DASHES, '-')
.replace(STRIP_LEADING_NON_LETTER, '')
.replace(STRIP_TRAILING_DASH, '')
.slice(0, FILESET_NAME_MAX_LENGTH)
.replace(STRIP_TRAILING_DASH, '');

// Min length is 2 (one leading letter + at least one body char).
if (s.length < 2) s = FALLBACK;
return s;
return toValidEntityName(input, FALLBACK);
}
Original file line number Diff line number Diff line change
@@ -1,37 +1,24 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { FILESET_NAME_MAX_LENGTH, FILESET_NAME_REGEXP } from '@nemo/common/src/utils/filesetName';
import { entityNameSchema } from '@nemo/common/src/utils/entityName';
import { FilesetPurpose } from '@nemo/sdk/generated/platform/schema';
import { FilesCreateFilesetBody } from '@nemo/sdk/generated/platform/zod/files';
import { z } from 'zod';

const NAME_REQUIRED_MESSAGE = 'Name is required.';

const NAME_PATTERN_MESSAGE =
'Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, dots, underscores, plus, and @ (no consecutive hyphens, cannot end with a hyphen).';

export enum StorageMode {
Local = 'local',
External = 'external',
}

export type SupportedPurpose = typeof FilesetPurpose.dataset | typeof FilesetPurpose.model;

// Override the SDK-generated `name` validation. The generated zod uses the
// Files service DTO's loose pattern (`^[\w\-.]+$`, max 255); the entity store
// downstream enforces a stricter RFC-1035-ish pattern. We validate against the
// strict one here so the user sees a useful error instead of a 422 toast.
// Same pattern as the generated zod, but reports which rule the value breaks.
export const filesetCreateFormSchema = FilesCreateFilesetBody.pick({
name: true,
description: true,
}).extend({
name: z
.string()
.trim()
.min(1, NAME_REQUIRED_MESSAGE)
.max(FILESET_NAME_MAX_LENGTH)
.regex(FILESET_NAME_REGEXP, NAME_PATTERN_MESSAGE),
name: z.string().trim().pipe(entityNameSchema('Name')),
url: z.string().optional(),
secretKey: z.string().optional(),
});
Expand Down
5 changes: 0 additions & 5 deletions web/packages/studio/src/routes/FilesetNewRoute/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,6 @@

import { FilesetPurpose } from '@nemo/sdk/generated/platform/schema';

export const DATASET_NAME_REQUIRED_MESSAGE = 'Name is required.';

export const DATASET_NAME_PATTERN_MESSAGE =
'Name must start with a lowercase letter, be 2–63 characters, and contain only lowercase letters, digits, hyphens, dots, underscores, plus, and @ (no consecutive hyphens, cannot end with a hyphen).';

/** Per-purpose copy shown in the purpose selector. Kept adjacent to the enum so each value has user-facing explanation. */
export const PURPOSE_OPTIONS: {
value: FilesetPurpose;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,9 @@ describe('FilesetNewRoute', () => {
await user.type(nameInput, 'tiny-gpt2-A');
await user.tab();

expect(await screen.findByText(/must start with a lowercase letter/i)).toBeInTheDocument();
expect(
await screen.findByText(/Name must be lowercase\. Try "tiny-gpt2-a"\./)
).toBeInTheDocument();
});

it('shows inline validation error when fileset name starts with a digit', async () => {
Expand Down
Loading
Loading