Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

shell: Manifest validation upon import #21449

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

mvollmer
Copy link
Member

@mvollmer mvollmer commented Dec 18, 2024

  • unit tests

@mvollmer mvollmer added the blocked Don't land until something else happens first (see task list) label Dec 18, 2024
@mvollmer mvollmer changed the title Shell validation shell: Manifest validation upon import Dec 18, 2024
@mvollmer
Copy link
Member Author

This is quite WIPy still...

@mvollmer mvollmer added no-test For doc/workflow changes, or experiments which don't need a full CI run, and removed blocked Don't land until something else happens first (see task list) labels Dec 18, 2024
This way, it is only validated once and not on every render.
@mvollmer mvollmer removed the no-test For doc/workflow changes, or experiments which don't need a full CI run, label Jan 7, 2025
This make sure that typed code never sees values that don't conform to
the claimed types, no matter what people put into their manifests.
Comment on lines +169 to +171
function validation_error(msg: string): never {
console.error(`JSON validation error for ${validation_path.join("")}: ${msg}`);
throw new ValidationError();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 3 added lines are not executed by any test.

export function import_string(val: JsonValue): string {
if (typeof val == "string")
return val;
validation_error(`Not a string: ${JSON.stringify(val)}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

export function import_number(val: JsonValue): number {
if (typeof val == "number")
return val;
validation_error(`Not a number: ${JSON.stringify(val)}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

Comment on lines +186 to +189
export function import_boolean(val: JsonValue): boolean {
if (typeof val == "boolean")
return val;
validation_error(`Not a boolean: ${JSON.stringify(val)}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 4 added lines are not executed by any test.

export function import_json_object(val: JsonValue): JsonObject {
if (!!val && typeof val == "object" && val.length === undefined)
return val as JsonObject;
validation_error(`Not an object: ${JSON.stringify(val)}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we want to add a "length" property to some future thing we store in manifests?

Comment on lines +238 to +240
} catch (e) {
if (!(e instanceof ValidationError))
throw e;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 3 added lines are not executed by any test.

Comment on lines +245 to +246
if (obj[field as string] === undefined) {
validation_error(`Field "${String(field)}" is missing`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 2 added lines are not executed by any test.


export function validate<T>(path: string, val: JsonValue | undefined, importer: (val: JsonValue) => T, fallback: T): T {
if (val === undefined)
return fallback;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

Comment on lines +257 to +260
} catch (e) {
if (!(e instanceof ValidationError))
throw e;
return fallback;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 4 added lines are not executed by any test.

@@ -144,7 +142,7 @@ export const LangModal = ({ dialogResult }) => {
<MenuList>
{
(() => {
const locales = manifest.locales || {};
const locales = state.config.manifest.locales || {};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

@mvollmer mvollmer requested a review from martinpitt January 8, 2025 10:01
@mvollmer mvollmer marked this pull request as ready for review January 8, 2025 10:01
Copy link
Member

@martinpitt martinpitt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @mvollmer ! I like the approach a lot -- explicit, clean, no new dependencies, and pleasant to use.

QUnit.test("import_array", function(assert) {
assert.deepEqual(import_array(["a", "b", "c"], import_string), ["a", "b", "c"], "array of strings");
assert.deepEqual(import_array([1, 2, 3], import_number), [1, 2, 3], "array of numbers");
assert.deepEqual(import_array([1, 2, "c"], import_number), [1, 2], "array of numbers with a string");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO this should also throw, instead of giving you a truncated array -- that just silently hides errors, and may make the rest of the entries invalid as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is the big philosophical question. Personally, I like the non-strict approach, but I have no problem with making this strict.

My best argument for making this strict is to amplify that something is wrong. If you make a mistake with your search terms, your whole page doesn't get loaded instead of some search terms being omitted.


QUnit.test("import_record", function(assert) {
assert.deepEqual(
import_record({ a: "a", b: "b", c: "c" }, import_string),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON parlance is "object", not "record". I suppose you want to give this a different name to point out that all values have to be of the same type. But a "record" (or "struct") is precisely the opposite -- fixed number and names of keys, and different values.

One term that comes to my mind that conveys this meaning is "dictionary" (they usually map one type to a single value type).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "record" term is from the TypeScript utility class Record<Keys, Type>, which is indeed a Map or Dict, not a structure.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh -- this is opposite to pretty much everywhere else https://en.wikipedia.org/wiki/Record_(computer_science) -- shame on you, MS.


import { JsonValue } from 'cockpit';
import {
import_json_object, import_optional, import_mandatory,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_() would be more consistent with our Python API; "import" sounds a bit exaggerated, but no strong opinion.

Comment on lines +103 to +106
assert.deepEqual(
import_record({ a: 1, b: 2, c: "c" }, import_number),
{ a: 1, b: 2 },
"record of numbers with a string");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the inconsistent array above, IMHO this should throw and ignore the value in its entirety.

Comment on lines +47 to +51
Validation is generally lenient: If a validation error occurs deep
inside a nested structure, only the affected part of the structure
is omitted. More conretely, if an element of an array is invalid,
this element is omitted from the array. If a optional field of an
object is invalid, it will be omitted. This doesn't happen
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what I mentioned above: IMHO this isn't safe in general. Think about "bridges": [] in the shell manifest. Silently ignoring a non-string argument from sudo or pkexec is potentially disastrous. It's of course less of an issue for docs or locales, but IMHO it would make more sense to be strict and completely ignore the value -- that will greatly increase the chance of catching bugs in our tests.

(Also, "conretely" typo)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, command lines are a very good example of something that better be rejected in its entirety.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put me also in the "reject outright" camp.

const checks: [JsonValue, Teams][] = [
[
{
MAC: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I just have to ask: What does "MAC" mean in soccer context?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's supposed to be the short form of "ManCity"...

{ name: "Haaland", age: 24 },
{ name: "De Bruyne", age: 33 }
],
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please add a description comment before each scenario? Like, this one is probably "No stadium (it's optional)" or so

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep.


function validation_error(msg: string): never {
console.error(`JSON validation error for ${validation_path.join("")}: ${msg}`);
throw new ValidationError();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't hurt to pass on msg into the exception object?

pkg/lib/import-json.ts Show resolved Hide resolved
pkg/shell/machines/machines.js Show resolved Hide resolved
try {
with_validation_path(`.${String(field)}`, () => { res[field] = importer(obj[field]) });
} catch (e) {
if (!(e instanceof ValidationError))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with silently dropping validation errors. It should either be "the key exists and is valid" or "the key does not exist". The key existing with an invalid type is definitely an error.

}

export function import_optional<T, F extends keyof T>(res: T, obj: JsonObject, field: F, importer: (val: JsonValue) => T[F]): void {
if (obj[field as string] === undefined)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not in or whatever? The as is a bit unsightly here.

You could probably also further constraint the type of F to be keyof T & string.

return res;
}

export function import_optional<T, F extends keyof T>(res: T, obj: JsonObject, field: F, importer: (val: JsonValue) => T[F]): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(thinking out loud — not necessarily a very strong request for change or anything)

The way this function works is a bit weird. The assumption that the name of the input key and output key are the same is probably true for most cases, but might not be what the user wants. It also involves using mutable state, which I generally try to avoid if possible.

Two possible alternatives:

  • just write null or undefined explicitly to the dictionary. That's what the equivalent code in the bridge does (although str | None is a lot more Pythonic — JS seems to be quite happy when just blindly deferencing things and getting undefined).
  • Do something like having this function return something like { F?: T } and use it like:
const myobj = {
  field: import_mandatory(input, "x", ...),
  ... import_optional(input, "y", ...),
  ... import_optional(input, "z", ...)
};

ie: the function will return either an empty dictionary or the dictionary to merge with the optional value.

I sort of like the null thing, personally. It clashes with in and ?, though...

}

export function import_mandatory<T>(obj: JsonObject, field: string, importer: (val: JsonValue) => T): T {
if (obj[field as string] === undefined) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More as abuse...


export function import_json_array(val: JsonValue): JsonValue[] {
if (!!val && typeof val == "object" && val.length !== undefined)
return val as JsonValue[];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should try to get this working without resorting to unsafe casts. The compiler has a fairly impressive set of narrowing mechanisms...

See also https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray

export function import_json_object(val: JsonValue): JsonObject {
if (!!val && typeof val == "object" && val.length === undefined)
return val as JsonObject;
validation_error(`Not an object: ${JSON.stringify(val)}`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we want to add a "length" property to some future thing we store in manifests?


export function import_json_object(val: JsonValue): JsonObject {
if (!!val && typeof val == "object" && val.length === undefined)
return val as JsonObject;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...as...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants