Scille / parsec-cloud

Open source Dropbox-like file sharing with full client encryption !
https://parsec.cloud
Other
269 stars 40 forks source link

🚧 Rust protocol schema testing 🚧 #2834

Open FirelightFlagboy opened 2 years ago

FirelightFlagboy commented 2 years ago

Rust protocol schema testing

How to test if the change made to the protocol schemes (oxidation/libparsec/crates/{protocol,client_types}/schema/**.json) is valid ?

TODOS

Assumption

  1. Major/Minor versions are incremental (i.e.: we can't have a version 3 if the version 2 don't exist. Same if version 1 don't exist)

Change in the json schemes

Some change are required in the json schemes:

For protocol scheme

type ProtocolScheme = {
    label: string,
    major_versions: number[],
    introduced_in?: MajorMinorString,
    req: Request,
    reps: Responses,
    nested_types?: NestedTypes,
}[]

type Request = RequestWithFields | RequestUnit;

interface RequestWithFields {
    cmd: string,
    other_fields: Fields,
}

interface RequestUnit {
    cmd: string,
    other_fields: Fields,
}

interface Fields {
    [name: string]: {
        type: string,
        introduced_in?: MajorMinorString,
    }
}

interface Responses {
    [status: string]: |
    {
        other_fields: Fields,
    } |
    {
        unit: string
    } 
}

interface NestedTypes {
    [label: string]: |
    // either a enum
    {
        discriminant_field: string,
        variants: Variants,
    } |
    // or a struct
    {
        fields: Fields
    }
}

interface Variants {
    [name: string]: {
        discriminant_value: string,
        fields: Fields,
    }
}

type MajorMinorString = `${Major}.${Minor}`
type Major = number
type Minor = number

Example protocol scheme

[
    {
        "label": "FooBar",
        "major_versions": [
            1,
            2
        ],
        "req": {
            "cmd": "foo_bar",
            "other_fields": {
                "human_handle": {
                    "type": "Option<HumanHandle>",
                    "introduced_in": "1.1"
                }
            }
        },
        "reps": {
            "ok": {
                "other_fields": {}
            }
        }
    },
    {
        "label": "FooBar",
        "major_versions": [
            3
        ],
        "req": {
            "cmd": "foo_bar",
            "other_fields": {
                "human_handle": {
                    "type": "HumanHandle" 
                }
            }
        },
        "reps": {
            "ok": {
                "other_fields": {}
            },
            "error": {
                "other_fields": {
                    "reason": {
                        "type": "String"
                    }
                }
            }
        }
    }
]

From the example above, we have the field human_handle that is present on versions >=1.1 (including 2.*)

For type scheme

type TypeScheme = {
    label: string,
    major_versions: number[],
    introduced_in?: MajorMinorString,
    other_fields: Fields
}[]

Example type scheme

[
    {
        "label": "FooBarType",
        "major_versions": [
            1,
            2,
        ],
        "other_fields": {
            "bar": {
                "type": "Int64"
            },
            "foo": {
                "type": "FooType",
                "introduced_in": "1.1"
            }
        }
    }
]

From the example above, we have the field foo that's is present on versions >=1.1 (including 2.*)

Testing the scheme

Testing retro-compatibility

We want to verify how a new version is compared to the previous versions.

Testing retro-compatibility on major versions (MAJOR-ENEMIES)

When creating a new major version e.g. 2.0, we MUST check if this version is not compatible with the previous version 1.*

Testing retro-compatibility on minor versions (MINOR-ALLIES)

When creating a new minor version e.g. 1.3, we MUST check if this version is compatible with the previous version 1.2.

Testing multiple unreleased version (SQUASH-UNRELEASED)

During the development process, we may need to edit the api protocol but we don't want to have multiple unreleased version.

If we haven't release the version 2.1, we don't need to create the version 2.2.

Testing readonly older released version (STABLE-RELEASE)

We want to check if we aren't editing a previously released api version.

For that we will need to have a list of released versions and check if those weren't edited

Testing introduced_in

We MUST check the value in introduced_in, the value must respond to the following criteria:

  1. The value MUST be valid for the type MajorMinorString
  2. The major part MUST be listed in major_versions
  3. The minor part MUST be >0
touilleMan commented 2 years ago

Ensure that minor versions are compatible to each other (i.e.: v1.1 with v1.3).

Just to be sure: we don't need to test v1.1 against v1.3, (but only v1.1 against v1.2, then v1.2 against v1.3)

I guess it's the same thing when test a major version: considering we have versions v1.0 v1.1 v2.0 v2.1, we only need to a single v2 version against a single v1 (typically last v2 against last v1) to ensure there is breaking changes between the two

From the example above, we have the field human_handle that is present in version ~=1.1 but not in version >=2.0

That's interesting: it seemed clear to me that "introduced_in": [{ "major": 1, "minor": 1 },] meant this field is present in ~=1.1 and 2.0 (I expected that if the field disappear an a major version 2, this version should have it own full schema and not share it with version 1)

So we may want of a more explicit way to describe this. For instance if the introduced_in only accept a single value (i.e. "introduced_in": { "major": 1, "minor": 1 }, then it's obvious that the field is optional in major version 1 and always present in major version 2 (and if we want to remove the field from major version 2, we must have a separated schema which is explicit)

For protocol scheme

I think the introduced_in field should also be present for nested_types's fields

For type scheme

For the moment we don't have a versionning for type scheme (we consider we should never break compat, otherwise encrypted data would no longer be readable). But we can consider we are currently in major version 1 in order to have a json format similar to what is done for protocol scheme. And obviously tests for type scheme should only allow a v1 major version !

FirelightFlagboy commented 2 years ago

I've updated the issue body:

FirelightFlagboy commented 2 years ago

I've updated the issue body:

FirelightFlagboy commented 2 years ago

One thing that appear to be missing is how to we separate the different major version of an protocol.

Protocol using enum variant to separate major version.

mod ping {
    enum Ping {
        V1(PingV1V2),
        V2(PingV1V2),
        V3(PingV3)
    }

    struct PingV1V2 {
        // ...
    }

    struct PingV3 {
        // ...
    }
}

Protocol using sub-module to separate major version.

mod ping {
    mod v1v2 {
        struct Ping {
            // ...
        }
        // ...
    }
    mod v1 {
        pub use super::v1v2::Ping;
        // ...
    }
    mod v2 {
        pub use super::v1v2::Ping;
        // ...
    }
    mod v3 {
        struct Ping {
            // ...
        }
        // ...
    }
}

Protocols using module to separate major version.

mod v1 {
    mod ping {
        struct Ping {
            // ...
        }
        // ...
    }
    // ...
}
mod v2 {
    mod ping {
        struct Ping {
            // ...
        }
        // ...
    }
    // ...
}
mod v3 {
    mod ping {
        struct Ping {
            // ...
        }
        // ...
    }
    // ...
} 
FirelightFlagboy commented 2 years ago

So it appear that the proposal Protocol using module to separate major version. is preferred

FirelightFlagboy commented 2 years ago

I've updated the original issue with the following change:

FirelightFlagboy commented 2 years ago

I've update the original issue with the following change: