Skip to content

Commit 3da9a08

Browse files
authored
Merge pull request #754 from Dstack-TEE/feat/manifest-version-os-policy
guest: enforce app compose version policy
2 parents bdc3999 + 354051d commit 3da9a08

13 files changed

Lines changed: 704 additions & 39 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ scale = { version = "3.7.4", package = "parity-scale-codec", features = [
154154
] }
155155
serde = { version = "1.0.228", features = ["derive"], default-features = false }
156156
serde-human-bytes = "0.1.2"
157+
semver = "1.0.28"
157158
serde_jcs = "0.2.0"
158159
rmp-serde = "1.3.1"
159160
serde_json = { version = "1.0.140", default-features = false }

dstack-attest/src/attestation.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,6 +1047,21 @@ mod compatibility_tests {
10471047
assert_eq!(AttestationMode::DstackAmdSevSnp.encode(), vec![3]);
10481048
}
10491049

1050+
#[test]
1051+
fn attestation_mode_deserializes_canonical_names() {
1052+
let parse = |value| serde_json::from_str::<AttestationMode>(value).unwrap();
1053+
assert_eq!(parse(r#""dstack-tdx""#), AttestationMode::DstackTdx);
1054+
assert_eq!(parse(r#""dstack-gcp-tdx""#), AttestationMode::DstackGcpTdx);
1055+
assert_eq!(
1056+
parse(r#""dstack-amd-sev-snp""#),
1057+
AttestationMode::DstackAmdSevSnp
1058+
);
1059+
assert_eq!(
1060+
parse(r#""dstack-nitro-enclave""#),
1061+
AttestationMode::DstackNitroEnclave
1062+
);
1063+
}
1064+
10501065
#[test]
10511066
fn attestation_quote_scale_discriminants_preserve_existing_wire_values() {
10521067
let gcp = AttestationQuote::DstackGcpTdx(DstackGcpTdxQuote {

dstack-types/src/lib.rs

Lines changed: 225 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ impl TdxAttestationVariant {
6767

6868
#[derive(Deserialize, Serialize, Debug, Clone)]
6969
pub struct AppCompose {
70-
pub manifest_version: u32,
70+
#[serde(deserialize_with = "deserialize_manifest_version")]
71+
pub manifest_version: String,
7172
pub name: String,
7273
// Deprecated
7374
#[serde(default)]
@@ -105,6 +106,103 @@ pub struct AppCompose {
105106
/// optional port whitelist).
106107
#[serde(default)]
107108
pub port_policy: PortPolicy,
109+
/// Guest-side requirements enforced by guests that understand this field.
110+
///
111+
/// Use manifest_version "3" (string) when setting this field so older
112+
/// guests, which only accept numeric manifest versions, fail closed instead
113+
/// of silently ignoring the requirements.
114+
#[serde(default, skip_serializing_if = "Option::is_none")]
115+
pub requirements: Option<Requirements>,
116+
}
117+
118+
#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq, Eq)]
119+
#[serde(default, deny_unknown_fields)]
120+
pub struct Requirements {
121+
/// OS-version requirement parsed with Rust semver requirement semantics,
122+
/// e.g. `">=0.6.0"` or `">=0.6.0, <0.7.0"`.
123+
#[serde(skip_serializing_if = "Option::is_none")]
124+
pub os_version: Option<String>,
125+
/// Allowed attestation platforms. Omitted means any supported platform;
126+
/// an explicit empty list means no platform is allowed.
127+
#[serde(skip_serializing_if = "Option::is_none")]
128+
pub platforms: Option<Vec<String>>,
129+
}
130+
131+
impl Requirements {
132+
pub fn is_empty(&self) -> bool {
133+
self.os_version.is_none() && self.platforms.is_none()
134+
}
135+
}
136+
137+
fn deserialize_manifest_version<'de, D>(deserializer: D) -> Result<String, D::Error>
138+
where
139+
D: serde::Deserializer<'de>,
140+
{
141+
struct ManifestVersionVisitor;
142+
143+
impl<'de> serde::de::Visitor<'de> for ManifestVersionVisitor {
144+
type Value = String;
145+
146+
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147+
formatter.write_str("a string manifest version, or legacy numeric 1/2")
148+
}
149+
150+
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
151+
where
152+
E: serde::de::Error,
153+
{
154+
parse_manifest_version_string(value).map_err(E::custom)
155+
}
156+
157+
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
158+
where
159+
E: serde::de::Error,
160+
{
161+
self.visit_str(&value)
162+
}
163+
164+
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
165+
where
166+
E: serde::de::Error,
167+
{
168+
match value {
169+
1 | 2 => Ok(value.to_string()),
170+
_ => Err(E::custom(
171+
"numeric manifest_version is only supported for legacy versions 1 and 2; use a string for newer versions",
172+
)),
173+
}
174+
}
175+
176+
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
177+
where
178+
E: serde::de::Error,
179+
{
180+
let value = u64::try_from(value)
181+
.map_err(|_| E::custom("manifest_version must be a positive integer"))?;
182+
self.visit_u64(value)
183+
}
184+
}
185+
186+
deserializer.deserialize_any(ManifestVersionVisitor)
187+
}
188+
189+
fn parse_manifest_version_string(value: &str) -> Result<String, String> {
190+
let value = value.trim();
191+
if value.is_empty() {
192+
return Err("manifest_version must not be empty".to_string());
193+
}
194+
let parsed = value.parse::<u32>().map_err(|_| {
195+
format!("manifest_version must be a positive integer string, got {value:?}")
196+
})?;
197+
if parsed == 0 {
198+
return Err("manifest_version must be greater than 0".to_string());
199+
}
200+
if parsed.to_string() != value {
201+
return Err(format!(
202+
"manifest_version must be a canonical integer string, got {value:?}"
203+
));
204+
}
205+
Ok(parsed.to_string())
108206
}
109207

110208
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
@@ -180,6 +278,10 @@ pub struct DockerConfig {
180278
}
181279

182280
impl AppCompose {
281+
pub fn manifest_version_u32(&self) -> Option<u32> {
282+
self.manifest_version.parse().ok()
283+
}
284+
183285
pub fn feature_enabled(&self, feature: &str) -> bool {
184286
self.features.contains(&feature.to_string())
185287
}
@@ -208,6 +310,128 @@ impl AppCompose {
208310
}
209311
}
210312

313+
#[cfg(test)]
314+
mod app_compose_tests {
315+
use super::*;
316+
317+
fn parse_compose(manifest_version: serde_json::Value) -> serde_json::Result<AppCompose> {
318+
serde_json::from_value(serde_json::json!({
319+
"manifest_version": manifest_version,
320+
"name": "test",
321+
"runner": "docker-compose"
322+
}))
323+
}
324+
325+
#[test]
326+
fn manifest_version_accepts_string_versions() {
327+
let compose = parse_compose(serde_json::json!("3")).unwrap();
328+
assert_eq!(compose.manifest_version, "3");
329+
assert_eq!(compose.manifest_version_u32(), Some(3));
330+
}
331+
332+
#[test]
333+
fn manifest_version_accepts_legacy_numeric_1_and_2() {
334+
assert_eq!(
335+
parse_compose(serde_json::json!(1))
336+
.unwrap()
337+
.manifest_version,
338+
"1"
339+
);
340+
assert_eq!(
341+
parse_compose(serde_json::json!(2))
342+
.unwrap()
343+
.manifest_version,
344+
"2"
345+
);
346+
}
347+
348+
#[test]
349+
fn manifest_version_rejects_new_numeric_versions() {
350+
let err = parse_compose(serde_json::json!(3)).unwrap_err();
351+
assert!(err.to_string().contains("legacy versions 1 and 2"));
352+
}
353+
354+
#[test]
355+
fn manifest_version_rejects_invalid_numeric_values() {
356+
let err = parse_compose(serde_json::json!(0)).unwrap_err();
357+
assert!(err.to_string().contains("legacy versions 1 and 2"));
358+
let err = parse_compose(serde_json::json!(-1)).unwrap_err();
359+
assert!(err.to_string().contains("positive integer"));
360+
assert!(parse_compose(serde_json::json!(2.5)).is_err());
361+
}
362+
363+
#[test]
364+
fn manifest_version_rejects_non_canonical_strings() {
365+
let err = parse_compose(serde_json::json!("0")).unwrap_err();
366+
assert!(err.to_string().contains("greater than 0"));
367+
let err = parse_compose(serde_json::json!("03")).unwrap_err();
368+
assert!(err.to_string().contains("canonical integer string"));
369+
let err = parse_compose(serde_json::json!("+3")).unwrap_err();
370+
assert!(err.to_string().contains("canonical integer string"));
371+
let err = parse_compose(serde_json::json!("")).unwrap_err();
372+
assert!(err.to_string().contains("must not be empty"));
373+
assert!(parse_compose(serde_json::json!("3.0")).is_err());
374+
}
375+
376+
#[test]
377+
fn requirements_support_os_version_and_platforms() {
378+
let compose: AppCompose = serde_json::from_value(serde_json::json!({
379+
"manifest_version": "3",
380+
"name": "test",
381+
"runner": "docker-compose",
382+
"requirements": {
383+
"os_version": ">=0.6.1",
384+
"platforms": ["dstack-gcp-tdx", "dstack-tdx"]
385+
}
386+
}))
387+
.unwrap();
388+
let requirements = compose.requirements.as_ref().unwrap();
389+
assert_eq!(requirements.os_version.as_deref(), Some(">=0.6.1"));
390+
assert_eq!(
391+
requirements.platforms,
392+
Some(vec!["dstack-gcp-tdx".to_string(), "dstack-tdx".to_string()])
393+
);
394+
395+
let err = serde_json::from_value::<AppCompose>(serde_json::json!({
396+
"manifest_version": "3",
397+
"name": "test",
398+
"runner": "docker-compose",
399+
"requirements": {
400+
"os_version_policy": ">=0.6.1"
401+
}
402+
}))
403+
.unwrap_err();
404+
assert!(err.to_string().contains("unknown field"));
405+
}
406+
407+
#[test]
408+
fn requirements_distinguish_omitted_and_empty_platforms() {
409+
let omitted: AppCompose = serde_json::from_value(serde_json::json!({
410+
"manifest_version": "3",
411+
"name": "test",
412+
"runner": "docker-compose",
413+
"requirements": {}
414+
}))
415+
.unwrap();
416+
let requirements = omitted.requirements.as_ref().unwrap();
417+
assert_eq!(requirements.platforms, None);
418+
assert!(requirements.is_empty());
419+
420+
let explicit_empty: AppCompose = serde_json::from_value(serde_json::json!({
421+
"manifest_version": "3",
422+
"name": "test",
423+
"runner": "docker-compose",
424+
"requirements": {
425+
"platforms": []
426+
}
427+
}))
428+
.unwrap();
429+
let requirements = explicit_empty.requirements.as_ref().unwrap();
430+
assert_eq!(requirements.platforms, Some(vec![]));
431+
assert!(!requirements.is_empty());
432+
}
433+
}
434+
211435
#[derive(Deserialize, Serialize, Debug, Clone)]
212436
pub struct SysConfig {
213437
#[serde(default)]

dstack-util/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ scale = { workspace = true, features = ["derive"] }
2323
schnorrkel.workspace = true
2424
serde.workspace = true
2525
serde-human-bytes.workspace = true
26+
semver.workspace = true
2627
serde_json.workspace = true
2728
sha2.workspace = true
2829
tokio = { workspace = true, features = ["full"] }

0 commit comments

Comments
 (0)