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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ One Ark is picked automatically. With several, select one with `-d` by locator,

## Output and automation

Default output is formatted for reading. Use `--json` for complete, exact values, with an indented result document on stdout and one JSON event per line on stderr. App reports pass through raw by default. Colour requires a terminal; `NO_COLOR` or `CLICOLOR=0` disables it.
Default output is formatted for reading. Use `--json` for complete, exact values, with an indented result document on stdout and one JSON event per line on stderr. App reports pass through raw by default. Color requires a terminal; `NO_COLOR` or `CLICOLOR=0` disables it.

Scripts and AI agents should read `ark help agents` first. Nothing prompts without a terminal or with `--json`, and the exit code says what happened. `ark help output` defines the streams, JSON fields and error codes.

Expand Down
34 changes: 33 additions & 1 deletion connect/src/emulator/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,15 @@ impl Instance {
}
}

/// The listing version this build understands. A breaking change to the
/// registry bumps it, so a newer registry is refused rather than misread.
const VERSION: u64 = 1;

/// Registry response with entries retained for individual decoding.
#[derive(Deserialize)]
struct Listing {
#[serde(default)]
version: Option<u64>, // Absent on a registry older than the contract
#[serde(default)]
instances: Vec<serde_json::Value>, // Entries decoded independently for compatibility
}
Expand All @@ -73,6 +79,15 @@ fn list_at(addr: SocketAddr) -> Result<Vec<Instance>, Error> {
};
let listing: Listing = serde_json::from_slice(&body)
.map_err(|err| Error::Registry(io::Error::new(io::ErrorKind::InvalidData, err)))?;
if listing.version != Some(VERSION) {
return Err(Error::Registry(io::Error::new(
io::ErrorKind::InvalidData,
match listing.version {
Some(version) => format!("emulator registry version {version} is not supported"),
None => "emulator registry listing carries no version".to_string(),
},
)));
}

// Skip entries this build cannot decode without losing compatible entries
// from the same registry response.
Expand Down Expand Up @@ -196,7 +211,7 @@ mod tests {
#[test]
fn test_chunked_listing() {
let (listener, addr) = bind();
let parts = ["{\"instances\":[", "{\"port\":18181}]}"];
let parts = ["{\"version\":1,\"instances\":[", "{\"port\":18181}]}"];
let mut response = String::from("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n");
for part in parts {
response.push_str(&format!("{:x}\r\n{part}\r\n", part.len()));
Expand All @@ -223,6 +238,23 @@ mod tests {
);
}

// Tests that a listing of a version this build does not know, or of no
// version at all, is refused rather than read.
#[test]
fn test_unknown_version_is_refused() {
for body in [
"{\"version\":2,\"instances\":[{\"port\":18181}]}",
"{\"instances\":[{\"port\":18181}]}",
] {
let (listener, addr) = bind();
serve(listener, format!("HTTP/1.1 200 OK\r\n\r\n{body}"));
assert!(
matches!(list_at(addr), Err(Error::Registry(error)) if error.kind() == io::ErrorKind::InvalidData),
"{body}"
);
}
}

// Tests that a service answering with anything but a listing fails the
// lookup, a refusal, a body of another shape or no answer at all.
#[test]
Expand Down
2 changes: 1 addition & 1 deletion connect/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
//! Firmware preparation may require approval, so a rejected proof refreshes cloud
//! keys and returns an error for the caller to retry explicitly.
//!
//! Downloads, package catalogues, version selection, caches, prompts, signal
//! Downloads, package catalogs, version selection, caches, prompts, signal
//! handling and reboot waits belong to callers. Connect accepts readers, checks
//! declared sizes and optional dataset hashes, and does not retry a failed
//! transfer. Progress supplies upload session and execution task IDs for explicit
Expand Down
15 changes: 6 additions & 9 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,13 @@ pub(crate) enum Command {
Unlock,
/// Give the Ark its attested identity
Enroll(Enroll),
/// Datasets: read with list/show/paths; change with upload/fetch/delete/repair
/// Read and change the datasets on the Ark
#[command(subcommand)]
Data(Data),
/// Apps: run, cancel
/// Run an app on the Ark, or cancel one
#[command(subcommand)]
App(App),
/// Firmware: list, update
/// List and install Ark firmware
#[command(subcommand)]
Firmware(Firmware),
/// Check this computer, the Ark and the cloud; suggest fixes
Expand All @@ -150,7 +150,7 @@ pub(crate) enum Command {
/// Command path or topic name
#[arg(num_args = 0.., value_name = "COMMAND_OR_TOPIC")]
path: Vec<String>,
// Print all command help and embedded topics as one manual.
/// Print the whole manual: every command page and every topic
#[arg(long, conflicts_with = "path")]
all: bool,
},
Expand Down Expand Up @@ -267,10 +267,7 @@ pub(crate) enum Firmware {
/// Plan the update without approval or installation
#[arg(long, conflicts_with = "unlock")]
dry_run: bool,
/// Verify the Ark returns running the target build (default)
#[arg(long, conflicts_with = "no_wait")]
wait: bool,
/// Return when installation is acknowledged
/// Return when installation is acknowledged, before the reboot is verified
#[arg(long)]
no_wait: bool,
},
Expand Down Expand Up @@ -402,7 +399,7 @@ mod tests {
"x",
"--no-cache",
],
vec!["ark", "firmware", "update", "--wait", "--no-wait"],
vec!["ark", "firmware", "update", "--dry-run", "--unlock"],
vec!["ark", "--quiet", "status", "-v"],
] {
assert!(
Expand Down
2 changes: 1 addition & 1 deletion src/data/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ mod tests {
assert_eq!(
text,
format!(
" / directory, + grantable, ! unavailable; details with --json\n v1/sample/ +\n groups/<item>/ !{}alpha, beta\n value{}A/G, T|T, A, ./., A/., ., AT/A, \n{}T/*, A/<DEL>\n summary\n v2/sample/a-long-example-directory-name/ !",
" / directory, + grantable, ! unavailable; details with --json\n v1/sample/ +\n groups/<item>/ !{}alpha, beta\n value{}A/G, T|T, A, ./., A/., ., AT/A,\n{}T/*, A/<DEL>\n summary\n v2/sample/a-long-example-directory-name/ !",
" ".repeat(26),
" ".repeat(35),
" ".repeat(46)
Expand Down
7 changes: 4 additions & 3 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,12 @@ impl Error {
self.hints.push(hint.into());
self
}
/// Encodes the result error; hints travel as stderr events instead.
/// Encodes the result error; hints travel as stderr events instead. The
/// Ark's number is a decimal string, as every 64-bit value is in JSON.
pub fn json(&self) -> Value {
let mut value = json!({"code": self.code, "message": self.message});
if let Some(remote) = &self.remote {
value["remote"] = json!({"code": remote.code, "message": remote.msg});
value["remote"] = json!({"code": remote.code.to_string(), "message": remote.msg});
}
value
}
Expand Down Expand Up @@ -214,7 +215,7 @@ mod tests {
assert_eq!(error.class, 5);
assert_eq!(error.code, "ark");
assert!(error.hints.is_empty());
assert_eq!(error.json()["remote"]["code"], code);
assert_eq!(error.json()["remote"]["code"], code.to_string());
assert_eq!(error.json()["remote"]["message"], "owner's verdict");
}
}
Expand Down
1 change: 0 additions & 1 deletion src/firmware/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ pub(crate) fn run(context: &Context, command: args::Firmware) -> Result<(), Erro
let args::Firmware::Update {
version,
dry_run,
wait: _,
no_wait,
} = command
else {
Expand Down
Loading
Loading