mod common;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use common::{assert_success, commit, git};
use serde::Deserialize;
const OWNER: &str = "did:web:owner.example";
const KNOT_HOST: &str = "knot.export.test";
const MASTER_KEY_ENV: &str = "KNOT_STATIC_EXPORT_MASTER_KEY";
const MASTER_KEY: [u8; 32] = [7; 32];
#[derive(Clone, Debug, Deserialize, PartialEq)]
struct RepoEntry {
repo_did: String,
fingerprint: String,
#[serde(default)]
public_refs: Vec<(String, String)>,
#[serde(default)]
cob_refs: Vec<(String, String)>,
#[serde(default)]
release_id: Option<String>,
#[serde(default)]
fetch_bases: Vec<Vec<String>>,
#[serde(default)]
shallow_branches: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct Manifest {
repos: Vec<RepoEntry>,
}
impl Manifest {
fn load(generation: &Path) -> Self {
let bytes = std::fs::read(generation.join("manifest.json")).expect("generation manifest");
serde_json::from_slice(&bytes).expect("parse generation manifest")
}
fn entry(&self, did: &str) -> &RepoEntry {
self.repos
.iter()
.find(|repo| repo.repo_did == did)
.unwrap_or_else(|| panic!("repo {did} missing from manifest"))
}
}
fn export_mount(generation: &Path, did: &str) -> PathBuf {
generation.join("www/_git").join(common::repo_token(did))
}
struct Env {
root: tempfile::TempDir,
scan: PathBuf,
output: PathBuf,
config: PathBuf,
sources: Vec<PathBuf>,
dids: Vec<String>,
}
impl Env {
/// each import saves locally, then fails to submit to the dead plc port.
fn new(rkeys: &[&str]) -> Self {
let root = tempfile::tempdir().expect("tempdir");
let scan = root.path().join("state");
let output = root.path().join("output");
let config = root.path().join("config.toml");
std::fs::create_dir_all(&scan).unwrap();
std::fs::write(
&config,
format!(
r#"[knot]
hostname = "{KNOT_HOST}"
owner = "{OWNER}"
[repo]
scan_path = "{}"
default_branch = "main"
object_format = "sha1"
[secrets]
sealed_key_file = "{}"
master_key_env = "KNOT_STATIC_EXPORT_MASTER_KEY"
[atproto]
plc_directory = "https://127.0.0.1:9"
[output]
root = "{}"
[activate]
nginx_binary = "true"
"#,
scan.display(),
root.path().join("keys.sealed").display(),
output.display(),
),
)
.unwrap();
let mut env = Self {
root,
scan,
output,
config,
sources: Vec::new(),
dids: Vec::new(),
};
assert_success(&env.run(["init"]), "knot-static init");
for rkey in rkeys {
let source = env.root.path().join(format!("source-{rkey}"));
seed_source(&source);
let imported = env.run([
"repo",
"import",
"--owner",
OWNER,
"--rkey",
rkey,
"--default-branch",
"main",
source.to_str().unwrap(),
]);
assert!(
!imported.status.success()
&& String::from_utf8_lossy(&imported.stderr).contains("submit plc operation"),
"import {rkey} persists locally then stops at the unreachable PLC: stderr={}",
String::from_utf8_lossy(&imported.stderr),
);
env.sources.push(source);
}
let listed = env.run(["repo", "list"]);
assert_success(&listed, "knot-static repo list");
env.dids = String::from_utf8(listed.stdout)
.unwrap()
.lines()
.filter_map(|line| line.split_whitespace().next().map(str::to_string))
.collect();
assert_eq!(env.dids.len(), rkeys.len(), "one hosted repo per rkey");
env
}
fn run(&self, args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>) -> Output {
common::knot_static_raw(&self.config, MASTER_KEY_ENV, &MASTER_KEY, args, &[])
}
fn generations(&self) -> Vec<PathBuf> {
let mut entries: Vec<_> = std::fs::read_dir(self.output.join("generations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_ok_and(|k| k.is_dir()))
// `.building-*` staging dirs fail the 64 hex check.
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.len() == 64 && n.bytes().all(|b| b.is_ascii_hexdigit()))
})
.map(|e| e.path())
.collect();
entries.sort();
entries
}
fn activate(&self, generation: &Path, context: &str) {
let id = generation.file_name().unwrap().to_str().unwrap();
assert_success(&self.run(["activate", id]), context);
}
fn hosted_repo(&self, repo_did: &str) -> PathBuf {
let parts: Vec<&str> = repo_did.splitn(3, ':').collect();
let msid = parts.get(2).copied().unwrap_or_default();
let (shard, remainder) = msid.split_at(
msid.char_indices()
.nth(2)
.map(|(i, _)| i)
.unwrap_or(msid.len()),
);
self.scan.join(parts[1]).join(shard).join(remainder)
}
}
fn seed_source(source: &Path) {
std::fs::create_dir_all(source).unwrap();
git(source, ["init", "-q", "-b", "main"]);
commit(
source,
"src/lib.rs",
"pub fn answer() -> u8 { 41 }\n",
"first",
);
commit(
source,
"src/lib.rs",
"pub fn answer() -> u8 { 42 }\n",
"second",
);
commit(
source,
"src/main.rs",
"fn main() { println!(\"{}\", export::answer()); }\n",
"third",
);
git(source, ["branch", "feature", "HEAD~1"]);
git(source, ["tag", "v1", "HEAD~1"]);
git(source, ["checkout", "-q", "--orphan", "private-seed"]);
git(source, ["rm", "-q", "-rf", "."]);
commit(
source,
"atproto-record/private.json",
"{\"private\":true}\n",
"private",
);
let secret = git(source, ["rev-parse", "HEAD"]);
git(source, ["checkout", "-q", "--orphan", "cob-seed"]);
git(source, ["rm", "-q", "-rf", "."]);
commit(source, "payload", "cob\n", "cob");
let cob = git(source, ["rev-parse", "HEAD"]);
git(source, ["checkout", "-q", "main"]);
git(source, ["branch", "-D", "private-seed"]);
git(source, ["branch", "-D", "cob-seed"]);
for name in [
"refs/hidden/secret",
"refs/atproto/secret",
"refs/cob-checkpoints/secret",
] {
git(source, ["update-ref", name, &secret]);
}
for name in ["refs/cobs/org.tangled.track.ticket/1", "refs/tngl/keep/x/1"] {
git(source, ["update-ref", name, &cob]);
}
}
fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
haystack
.windows(needle.len())
.any(|window| window == needle)
}
fn files_below(root: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.flat_map(|entry| {
let path = entry.path();
match entry.file_type() {
Ok(kind) if kind.is_dir() => files_below(&path),
_ => vec![path],
}
})
.collect()
}
#[test]
fn an_imported_repo_is_hosted_with_a_commit_graph() {
let env = Env::new(&["graph"]);
let hosted = env.hosted_repo(&env.dids[0]);
let tip = git(&hosted, ["rev-parse", "refs/heads/main"]);
assert!(
common::commit_graph_covers(&hosted, &tip),
"the import wrote a commit-graph covering the history it brought"
);
assert_success(
&common::git_raw(&hosted, ["commit-graph", "verify"]),
"git commit-graph verify",
);
}
#[test]
fn public_export_excludes_private_reachability() {
let env = Env::new(&["export"]);
assert_success(&env.run(["generate"]), "first knot-static generate");
let generation = env.generations().pop().unwrap();
assert_success(&env.run(["generate"]), "second knot-static generate");
assert_eq!(
env.generations(),
[generation.clone()],
"generation must be deterministic"
);
let did = &env.dids[0];
let manifest = Manifest::load(&generation);
let secret_oid = git(&env.sources[0], ["rev-parse", "refs/hidden/secret"]);
let cob_oid = git(
&env.sources[0],
["rev-parse", "refs/cobs/org.tangled.track.ticket/1"],
);
let forbidden = [
b"refs/cob-checkpoints/".as_slice(),
b"refs/hidden/".as_slice(),
b"refs/hiden/".as_slice(),
b"refs/atproto/".as_slice(),
b"atproto-record".as_slice(),
];
let export = export_mount(&generation, did);
for file in files_below(&export) {
let relative = file.strip_prefix(&export).unwrap().to_string_lossy();
let bytes = std::fs::read(&file).unwrap();
for needle in forbidden {
assert!(
!contains_subslice(relative.as_bytes(), needle)
&& !contains_subslice(&bytes, needle),
"Git export exposes {} in {}",
String::from_utf8_lossy(needle),
relative,
);
}
}
for v0 in ["advertisement", "upload-pack"] {
let bytes = std::fs::read(export.join("smart").join(v0)).unwrap();
assert!(
!contains_subslice(&bytes, b"refs/cobs") && !contains_subslice(&bytes, b"refs/tngl"),
"{v0} lists a cob or keep ref"
);
}
let present = common::git_command(&export)
.args(["cat-file", "-e", &format!("{cob_oid}^{{commit}}")])
.output()
.unwrap();
assert!(present.status.success(), "the cob commit is in the export");
let absent = common::git_command(&export)
.args(["cat-file", "-e", &format!("{secret_oid}^{{commit}}")])
.output()
.unwrap();
assert!(
!absent.status.success(),
"private-only commit reached export"
);
let clone = tempfile::tempdir().unwrap();
let cloned = clone.path().join("clone");
let output = common::git_command(clone.path())
.args([
"clone",
"-q",
export.to_str().unwrap(),
cloned.to_str().unwrap(),
])
.output()
.unwrap();
assert_success(&output, "clone sanitized export");
// checked on the export, since a clone adds `refs/remotes/origin/*` itself.
let actual: BTreeSet<_> = git(&export, ["ls-remote", "."])
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let oid = parts.next()?;
let name = parts.next()?;
(!name.starts_with("HEAD")).then(|| format!("{name} {oid}"))
})
.collect();
let expected: BTreeSet<_> = manifest
.entry(did)
.public_refs
.iter()
.chain(&manifest.entry(did).cob_refs)
.map(|(name, oid)| format!("{name} {oid}"))
.collect();
assert_eq!(actual, expected);
}
#[test]
fn rebuilt_export_answers_clients_of_the_published_state() {
let env = Env::new(&["export"]);
assert_success(&env.run(["generate"]), "first generate");
let first = env.generations().pop().unwrap();
env.activate(&first, "activate");
let did = &env.dids[0];
let published = Manifest::load(&first).entry(did).clone();
assert_eq!(
published.shallow_branches.len(),
1,
"feature's depth-1 reply"
);
assert!(published.fetch_bases.is_empty());
let repo = env.hosted_repo(did);
let old_main = git(&repo, ["rev-parse", "refs/heads/main"]);
let next = git(
&repo,
["commit-tree", "main^{tree}", "-p", "main", "-m", "next"],
);
git(&repo, ["update-ref", "refs/heads/main", &next]);
assert_success(&env.run(["generate"]), "generate after a push");
let second = env
.generations()
.into_iter()
.find(|generation| *generation != first)
.expect("the rebuilt generation");
let rebuilt = Manifest::load(&second).entry(did).clone();
assert_eq!(rebuilt.fetch_bases, vec![vec![old_main.clone()]]);
assert_eq!(rebuilt.shallow_branches, published.shallow_branches);
let smart = export_mount(&second, did).join("smart");
assert!(smart.join("have-0").is_file() && smart.join("have-0-done").is_file());
env.activate(&second, "activate the rebuilt generation");
let last = git(
&repo,
["commit-tree", "main^{tree}", "-p", "main", "-m", "last"],
);
git(&repo, ["update-ref", "refs/heads/main", &last]);
assert_success(&env.run(["generate"]), "generate after another push");
let third = env
.generations()
.into_iter()
.find(|generation| *generation != first && *generation != second)
.expect("the third generation");
assert_eq!(
Manifest::load(&third).entry(did).fetch_bases,
vec![vec![next], vec![old_main]]
);
}
#[test]
fn fetches_over_ssh_get_the_canned_replies() {
use base64::Engine as _;
let env = Env::new(&["export"]);
assert_success(&env.run(["generate"]), "generate");
let generation = env.generations().pop().unwrap();
env.activate(&generation, "activate");
let feature = git(&env.sources[0], ["rev-parse", "feature"]);
// like sshd, the words after the host are joined into one command.
let sshd = env.root.path().join("sshd");
std::fs::write(
&sshd,
"#!/bin/sh\nwhile [ \"$1\" = -o ]; do shift 2; done\nshift\nSSH_ORIGINAL_COMMAND=\"$*\" exec \"$KS\" --config \"$CFG\" git-ssh --operator\n",
)
.unwrap();
std::fs::set_permissions(&sshd, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let ssh = |dir: &Path, program: &str, args: &[&str]| {
common::without_repo_env(&mut Command::new(program))
.current_dir(dir)
.args(args)
.env("GIT_SSH_COMMAND", &sshd)
// git only sends GIT_PROTOCOL when it knows the ssh variant, which this pins to `ssh`.
.env("GIT_SSH_VARIANT", "ssh")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.env("KS", env!("CARGO_BIN_EXE_knot-static"))
.env("CFG", &env.config)
.env(
MASTER_KEY_ENV,
base64::engine::general_purpose::STANDARD.encode(MASTER_KEY),
)
.output()
.unwrap()
};
let remote = "git@knot:export";
let clones = env.root.path().join("ssh-clones");
std::fs::create_dir_all(&clones).unwrap();
let cloned = ssh(
&clones,
"git",
&["-c", "protocol.version=2", "clone", "-q", remote, "git"],
);
assert_success(&cloned, "git clone over ssh");
let clone = clones.join("git");
git(&clone, ["fsck", "--strict"]);
assert_eq!(git(&clone, ["rev-parse", "origin/feature"]), feature);
let ci = clones.join("ci");
git(&clones, ["init", "-q", ci.to_str().unwrap()]);
let fetched = ssh(&ci, "git", &["fetch", "-q", "--depth=1", remote, &feature]);
assert_success(&fetched, "depth-1 fetch of a branch over ssh");
assert_eq!(
std::fs::read_to_string(ci.join(".git/shallow"))
.unwrap()
.trim(),
feature
);
let gix = ssh(&clones, "gix", &["clone", remote, "gix"]);
assert_success(&gix, "gix clone over ssh");
git(&clones.join("gix"), ["fsck", "--connectivity-only"]);
git(&clones.join("gix"), ["cat-file", "-e", &feature]);
let v0 = ssh(
&clones,
"git",
&["-c", "protocol.version=0", "clone", "-q", remote, "v0"],
);
assert!(!v0.status.success());
let stderr = String::from_utf8_lossy(&v0.stderr);
assert!(stderr.contains("need git protocol v2"), "{stderr}");
let archive = ssh(
&clones,
"git",
&["archive", &format!("--remote={remote}"), "HEAD"],
);
assert!(!archive.status.success());
let stderr = String::from_utf8_lossy(&archive.stderr);
assert!(stderr.contains("only git fetch/push is served"), "{stderr}");
let unknown = ssh(
&clones,
"git",
&[
"-c",
"protocol.version=2",
"clone",
"-q",
"git@knot:nowhere",
"nowhere",
],
);
assert!(!unknown.status.success());
let stderr = String::from_utf8_lossy(&unknown.stderr);
assert!(stderr.contains("remote error: "), "{stderr}");
}
/// none of these peel to a commit, and the export keeps the tag objects as is.
#[test]
fn tags_of_trees_blobs_and_tags_build_and_serve() {
let env = Env::new(&["export"]);
let did = &env.dids[0];
let repo = env.hosted_repo(did);
git(
&repo,
["tag", "-a", "tree-tag", "-m", "a tree", "main^{tree}"],
);
let blob = git(&repo, ["rev-parse", "main:src/lib.rs"]);
git(&repo, ["tag", "blob-light", &blob]);
git(&repo, ["tag", "-a", "blob-tag", "-m", "a blob", &blob]);
git(
&repo,
["tag", "-a", "tag-of-tag", "-m", "a tag", "tree-tag"],
);
let kinds = [
("tree-tag", "tag"),
("blob-light", "blob"),
("blob-tag", "tag"),
("tag-of-tag", "tag"),
];
assert_success(&env.run(["generate"]), "generate with non-commit tags");
let first = env.generations().pop().unwrap();
env.activate(&first, "activate with non-commit tags");
let next = git(
&repo,
["commit-tree", "main^{tree}", "-p", "main", "-m", "next"],
);
git(&repo, ["update-ref", "refs/heads/main", &next]);
assert_success(&env.run(["generate"]), "generate after a push");
let second = env
.generations()
.into_iter()
.find(|generation| *generation != first)
.expect("the rebuilt generation");
let has_every_tag = |clone: &Path, context: &str| {
for (tag, kind) in kinds {
let oid = git(&repo, ["rev-parse", &format!("refs/tags/{tag}")]);
let name = format!("refs/tags/{tag}");
assert_eq!(git(clone, ["rev-parse", &name]), oid, "{context}: {tag}");
assert_eq!(
git(clone, ["cat-file", "-t", &oid]),
kind,
"{context}: {tag}"
);
}
git(clone, ["fsck", "--strict"]);
};
let tags = "refs/tags/*:refs/tags/*";
let fetched = tempfile::tempdir().unwrap();
git(fetched.path(), ["init", "-q"]);
for (n, generation) in [&first, &second].into_iter().enumerate() {
let url = format!("file://{}", export_mount(generation, did).display());
let clones = tempfile::tempdir().unwrap();
let full = clones.path().join("full");
git(clones.path(), ["clone", "-q", &url, full.to_str().unwrap()]);
has_every_tag(&full, &format!("clone of generation {n}"));
let shallow = clones.path().join("shallow");
git(
clones.path(),
["clone", "-q", "--depth=1", &url, shallow.to_str().unwrap()],
);
git(&shallow, ["fetch", "-q", "--depth=1", &url, tags]);
has_every_tag(&shallow, &format!("depth-1 fetch from generation {n}"));
git(fetched.path(), ["fetch", "-q", &url, tags]);
has_every_tag(fetched.path(), &format!("fetch from generation {n}"));
}
}
#[test]
fn unchanged_generate_short_circuits_on_active_state_and_rebuilds_on_inputs() {
let env = Env::new(&["export"]);
assert_success(&env.run(["generate"]), "first generate");
env.activate(&env.generations()[0], "activate");
let before = env.generations();
assert_success(&env.run(["generate"]), "unchanged generate");
assert_eq!(
env.generations(),
before,
"unchanged run must not build a new generation"
);
let repo = env.hosted_repo(&env.dids[0]);
git(
&repo,
["update-ref", "refs/heads/same-oid", "refs/heads/main"],
);
git(&repo, ["symbolic-ref", "HEAD", "refs/heads/same-oid"]);
assert_success(&env.run(["generate"]), "generate after symbolic flip");
let after_flip = env.generations();
assert_eq!(
after_flip.len(),
before.len() + 1,
"same-OID symbolic HEAD change must rebuild"
);
let flipped = after_flip
.iter()
.find(|g| !before.contains(g))
.expect("the rebuilt generation");
env.activate(flipped, "activate after symbolic flip");
let config = std::fs::read_to_string(&env.config).unwrap();
std::fs::write(
&env.config,
config.replace("default_branch = \"main\"", "default_branch = \"trunk\""),
)
.unwrap();
assert_success(&env.run(["generate"]), "generate after config change");
assert_eq!(
env.generations().len(),
after_flip.len() + 1,
"config change must rebuild even when every OID is unchanged"
);
let after_config = env.generations();
let rebuilt = after_config
.iter()
.find(|g| !after_flip.contains(g))
.expect("the config-change generation");
env.activate(rebuilt, "activate after config change");
assert_success(&env.run(["generate"]), "final unchanged generate");
assert_eq!(
env.generations().len(),
after_flip.len() + 1,
"no extra generation when unchanged"
);
}
#[test]
fn targeted_generate_preserves_other_repositories() {
let env = Env::new(&["export", "second"]);
assert_success(&env.run(["generate"]), "first generate");
let first_generation = env.generations().pop().unwrap();
let first_manifest = Manifest::load(&first_generation);
env.activate(&first_generation, "activate");
let repo_a = env.hosted_repo(&env.dids[0]);
let push_clone = env.root.path().join("push-clone-a");
std::fs::create_dir_all(&push_clone).unwrap();
git(&push_clone, ["clone", "-q", repo_a.to_str().unwrap(), "."]);
commit(&push_clone, "pushed.txt", "new\n", "pushed");
git(
&push_clone,
["push", "-q", "origin", "main:refs/heads/pushed-branch"],
);
git(&repo_a, ["rev-parse", "refs/heads/pushed-branch"]);
assert_success(
&env.run(["generate", "--repo", &env.dids[0]]),
"targeted generate",
);
let all = env.generations();
assert_eq!(
all.len(),
2,
"targeted generate builds exactly one new generation"
);
// ids are content hashes with no time order to pick the newest by.
let second_generation = all
.iter()
.find(|g| g.file_name() != first_generation.file_name())
.expect("the new generation")
.clone();
let second_manifest = Manifest::load(&second_generation);
assert_eq!(second_manifest.repos.len(), 2, "both repos stay served");
let (a, b) = (&env.dids[0], &env.dids[1]);
assert_eq!(
first_manifest.entry(b),
second_manifest.entry(b),
"repo b's active entry is carried verbatim"
);
assert_ne!(
first_manifest.entry(a).fingerprint,
second_manifest.entry(a).fingerprint,
"repo a rebuilt"
);
let mounted = |generation: &Path, did: &str| {
std::fs::canonicalize(export_mount(generation, did)).expect("export mount resolves")
};
assert_eq!(
mounted(&first_generation, b),
mounted(&second_generation, b),
"repo b's git export is the same sealed release"
);
assert_ne!(
mounted(&first_generation, a),
mounted(&second_generation, a),
"repo a re-exported"
);
let listing =
std::fs::read_to_string(second_generation.join("www/_atproto/list/50-0.json")).unwrap();
assert!(
listing.contains(a.as_str()) && listing.contains(b.as_str()),
"{listing}"
);
env.activate(&second_generation, "activate targeted generation");
assert_success(
&env.run(["generate", "--repo", a]),
"targeted unchanged generate",
);
assert_eq!(
env.generations().len(),
2,
"unchanged targeted generate must not build a new generation"
);
}
// a config change invalidates the carry, and every repo
// is recaptured, not just the hinted one.
#[test]
fn targeted_generate_after_config_change_recaptures_everything() {
let env = Env::new(&["export", "second"]);
assert_success(&env.run(["generate"]), "first generate");
let first = env.generations().pop().unwrap();
let first_manifest = Manifest::load(&first);
env.activate(&first, "activate");
let config = std::fs::read_to_string(&env.config).unwrap();
std::fs::write(
&env.config,
config.replace("default_branch = \"main\"", "default_branch = \"trunk\""),
)
.unwrap();
assert_success(
&env.run(["generate", "--repo", &env.dids[0]]),
"targeted generate after config change",
);
let all = env.generations();
assert_eq!(all.len(), 2);
let second = all
.iter()
.find(|g| g.file_name() != first.file_name())
.expect("the rebuilt generation");
let second_manifest = Manifest::load(second);
for did in &env.dids {
assert_ne!(
first_manifest.entry(did).fingerprint,
second_manifest.entry(did).fingerprint,
"repo {did} must be recaptured under the new config, not carried"
);
}
}
#[test]
fn common_git_reads_no_user_config() {
let dir = tempfile::tempdir().unwrap();
let (home, signed) = common::signing::signing_home(dir.path());
let repo = dir.path().join("repo");
std::fs::create_dir(&repo).unwrap();
for args in [
&["init", "-q"][..],
&["commit", "-q", "--allow-empty", "-m", "one"],
] {
let output = common::git_command(&repo)
.args(args)
.env("HOME", &home)
.env("XDG_CONFIG_HOME", home.join(".config"))
.output()
.unwrap();
assert_success(&output, &format!("git {args:?}"));
}
assert!(!signed.exists(), "a test commit was signed");
}