Skip to content

Commit fdf3309

Browse files
committed
guest: enforce app compose version policy
1 parent 4380a54 commit fdf3309

13 files changed

Lines changed: 656 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: 198 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,98 @@ 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+
Ok(parsed.to_string())
108201
}
109202

110203
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
@@ -180,6 +273,10 @@ pub struct DockerConfig {
180273
}
181274

182275
impl AppCompose {
276+
pub fn manifest_version_u32(&self) -> Option<u32> {
277+
self.manifest_version.parse().ok()
278+
}
279+
183280
pub fn feature_enabled(&self, feature: &str) -> bool {
184281
self.features.contains(&feature.to_string())
185282
}
@@ -208,6 +305,106 @@ impl AppCompose {
208305
}
209306
}
210307

308+
#[cfg(test)]
309+
mod app_compose_tests {
310+
use super::*;
311+
312+
fn parse_compose(manifest_version: serde_json::Value) -> serde_json::Result<AppCompose> {
313+
serde_json::from_value(serde_json::json!({
314+
"manifest_version": manifest_version,
315+
"name": "test",
316+
"runner": "docker-compose"
317+
}))
318+
}
319+
320+
#[test]
321+
fn manifest_version_accepts_string_versions() {
322+
let compose = parse_compose(serde_json::json!("3")).unwrap();
323+
assert_eq!(compose.manifest_version, "3");
324+
assert_eq!(compose.manifest_version_u32(), Some(3));
325+
}
326+
327+
#[test]
328+
fn manifest_version_accepts_legacy_numeric_1_and_2() {
329+
assert_eq!(
330+
parse_compose(serde_json::json!(1))
331+
.unwrap()
332+
.manifest_version,
333+
"1"
334+
);
335+
assert_eq!(
336+
parse_compose(serde_json::json!(2))
337+
.unwrap()
338+
.manifest_version,
339+
"2"
340+
);
341+
}
342+
343+
#[test]
344+
fn manifest_version_rejects_new_numeric_versions() {
345+
let err = parse_compose(serde_json::json!(3)).unwrap_err();
346+
assert!(err.to_string().contains("legacy versions 1 and 2"));
347+
}
348+
349+
#[test]
350+
fn requirements_support_os_version_and_platforms() {
351+
let compose: AppCompose = serde_json::from_value(serde_json::json!({
352+
"manifest_version": "3",
353+
"name": "test",
354+
"runner": "docker-compose",
355+
"requirements": {
356+
"os_version": ">=0.6.1",
357+
"platforms": ["dstack-gcp-tdx", "dstack-tdx"]
358+
}
359+
}))
360+
.unwrap();
361+
let requirements = compose.requirements.as_ref().unwrap();
362+
assert_eq!(requirements.os_version.as_deref(), Some(">=0.6.1"));
363+
assert_eq!(
364+
requirements.platforms,
365+
Some(vec!["dstack-gcp-tdx".to_string(), "dstack-tdx".to_string()])
366+
);
367+
368+
let err = serde_json::from_value::<AppCompose>(serde_json::json!({
369+
"manifest_version": "3",
370+
"name": "test",
371+
"runner": "docker-compose",
372+
"requirements": {
373+
"os_version_policy": ">=0.6.1"
374+
}
375+
}))
376+
.unwrap_err();
377+
assert!(err.to_string().contains("unknown field"));
378+
}
379+
380+
#[test]
381+
fn requirements_distinguish_omitted_and_empty_platforms() {
382+
let omitted: AppCompose = serde_json::from_value(serde_json::json!({
383+
"manifest_version": "3",
384+
"name": "test",
385+
"runner": "docker-compose",
386+
"requirements": {}
387+
}))
388+
.unwrap();
389+
let requirements = omitted.requirements.as_ref().unwrap();
390+
assert_eq!(requirements.platforms, None);
391+
assert!(requirements.is_empty());
392+
393+
let explicit_empty: AppCompose = serde_json::from_value(serde_json::json!({
394+
"manifest_version": "3",
395+
"name": "test",
396+
"runner": "docker-compose",
397+
"requirements": {
398+
"platforms": []
399+
}
400+
}))
401+
.unwrap();
402+
let requirements = explicit_empty.requirements.as_ref().unwrap();
403+
assert_eq!(requirements.platforms, Some(vec![]));
404+
assert!(!requirements.is_empty());
405+
}
406+
}
407+
211408
#[derive(Deserialize, Serialize, Debug, Clone)]
212409
pub struct SysConfig {
213410
#[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)