blctl 0.1.2

Manages BLS entries, grubenv and kernel cmdline options
Documentation
// blenv.rs
//
// Copyright 2019 Alberto Ruiz <aruiz@gnome.org>
//
// This file is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as
// published by the Free Software Foundation; either version 2.1 of the
// License, or (at your option) any later version.
//
// This file is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this program.  If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: LGPL-2.1-or-later

pub const BLS_ENTRIES: &str = "/boot/loader/entries";

use std::path::{Path, PathBuf};

use std::io;
use std::io::{Read, Write, ErrorKind};

extern crate ini;

use cmdline::*;

pub struct Environment {
  pub clamped: Option<usize>,
  pub envpath: PathBuf
}

macro_rules! parse_ini {
  ($envpath:expr) => {
    match ini::Ini::load_from_file($envpath) {
      Ok(val) => val,
      Err(_) => {
        return Err(io::Error::new(ErrorKind::InvalidData,
                                  "Could not parse environment data"));
      }
    }
  }
}

#[allow(dead_code)]
pub fn get_os_id () -> Option<String> {
  let ini = match ini::Ini::load_from_file("/etc/os-release") {
    Ok(ini) => ini,
    Err(_) =>  { return None; }
  };

  match ini.general_section().get("ID") {
    Some(val) => {
      let val = val.trim();
      if val == "rhel" {
        Some(String::from("redhat"))
      } else {
        Some(String::from(val))
      }
    },
    _ => None
  }
}

#[allow(dead_code)]
pub fn get_env_path () -> String {
  // $BOOTLOADER_ENV_PATH takes precedence
  match std::env::var_os("BOOTLOADER_ENV_PATH") {
    Some(path) => match path.into_string() {
      Ok(path) => { return path; },
      _ => {}
    }
    _ => {}
  };

  // If on an EFI system we load /boot/efi/EFI/
  if std::path::Path::new("/sys/firmware/efi").is_dir() {
    let id = get_os_id();
    if id.is_some () {
      let id = id.unwrap();

      //FIXME: Detect ESP mountpoint dynamically
      let esp = "/boot/efi/EFI";

      let efipath_legacy = format!("{}/{}/grubenv", esp, id);
      if std::path::Path::new(&efipath_legacy).is_file() {
        return efipath_legacy;
      }

      return format!("{}/{}/env", esp, id);
    };
  };

  String::from("/boot/grub2/grubenv")
}

impl Environment {
  pub fn new (clamped: Option<usize>, filepath: &str) -> io::Result<Environment> {
    let path = Path::new(filepath);
    let exists = path.exists();

    let clamp_size;
    let clamped = match clamped {
      None | Some(0) => { clamp_size = 0; None }
      Some(clamped) => { clamp_size = 512 * clamped as u64 ; Some(clamped) }
    };

    let mut grubenv = std::fs::OpenOptions::new().write(true)
                                                 .read(true)
                                                 .truncate(false)
                                                 .create(true)
                                                 .open(filepath)?;

    let file_size = std::fs::metadata(path)?.len();

    if exists {
      if clamped.is_some() && { file_size != clamp_size } {
        return Err(io::Error::new(io::ErrorKind::InvalidData, format!("Tried to open a file padded at {} bytes but was {}", clamp_size, file_size)));
      };

      let mut buffer = Vec::<u8>::new();
      buffer.resize("# GRUB Environment Block\n".len(), b'0');
      grubenv.read_exact(buffer.as_mut_slice())?;
      if String::from("# GRUB Environment Block\n").into_bytes() == buffer {
        Ok(Environment{envpath: path.to_path_buf(), clamped: clamped})
      } else {
        Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid GRUB environment file"))
      }
    } else {
      let _ = grubenv.write("# GRUB Environment Block\n".as_bytes());
      Ok(Environment{envpath: path.to_path_buf(), clamped: clamped})
    }
  }

  fn commit_env (&self, env_ini: ini::Ini) -> std::io::Result<()> {
    let mut content: Vec<u8> = Vec::new();
    for byte in "# GRUB Environment Block\n".as_bytes() {
      content.push(byte.clone());
    }
    env_ini.write_to(&mut content)?;

    if self.clamped.is_some() {
      // Number of 512 blocks
      let max = self.clamped.unwrap() * 512;
      if content.len() > max {
        return Err(io::Error::new(io::ErrorKind::InvalidData,
                                  format!("Could only write {} bytes but environment is {}", max, content.len())));
      }
      content.resize(max, b'#');
    }
    //TODO: Make this atomic by writing in a new
    std::fs::write(self.envpath.as_path(), content)?;
    Ok(())
  }

  pub fn get (&self, key: &str) -> std::io::Result<String> {
    let env_ini = parse_ini!(&self.envpath);

    let settings = match env_ini.section(None::<String>) {
      Some(section) => section,
      _ => { return Ok(String::new()); }
    };

    match settings.get(key) {
      Some(val) => Ok(val.clone()),
      _ => Err(io::Error::new(io::ErrorKind::InvalidInput,
                                  format!("'{}' variable not present in environment file", key)))
    }
  }

  pub fn show(&self) -> std::io::Result<String> {
    let mut output: Vec<u8> = Vec::new();
    let env_ini = parse_ini!(&self.envpath);
    let _ = env_ini.write_to(&mut output);
    unsafe {
      Ok(String::from_utf8_unchecked(output))
    }
  }

  pub fn set (&self, key: &str, value: &str) -> std::io::Result<()> {
    let mut env_ini: ini::Ini = parse_ini!(&self.envpath.as_path());
    {
      env_ini.with_section(None::<String>).set(String::from(key), String::from(value));
    }
    self.commit_env(env_ini)
  }

  pub fn unset (&self, key: &str) -> std::io::Result<()> {
    let mut env_ini: ini::Ini = parse_ini!(&self.envpath.as_path());
    {
      env_ini.general_section_mut().remove(&String::from(key));
    }
    self.commit_env(env_ini)
  }
}

impl CmdlineStore for Environment {
  fn cmdline_store(&mut self, cmdline: &Cmdline) -> std::io::Result<()> {
    match Cmdline::render(&cmdline) {
      Ok(cmdline) => {
        match self.set("kernelopts", &cmdline) {
          Ok(_) => Ok(()),
          Err(error) => {
            Err(io::Error::new(io::ErrorKind::InvalidData,
                                format!("{}", error)))
          }
        }
      },
      Err(error) => {
        Err(io::Error::new(io::ErrorKind::InvalidData,
                    format!("{}", error)))
      }
    }
  }

  fn cmdline (&self) -> std::io::Result<Cmdline> {
    let cmdline = match self.get("kernelopts") {
      Ok(cmdline) => cmdline,
      _ => String::new()
    };
    cmdline_parse(cmdline.as_str())
  }
}

#[cfg(test)]
mod blenv_tests {
  use std::fs;
  use blenv;

  extern crate tempfile;
  extern crate serial_test_derive;
  use self::serial_test_derive::serial;

  fn tests_init () -> (tempfile::TempDir, std::path::PathBuf) {
    let tmpdir = tempfile::tempdir().expect("Could not create temp dir");
    let mut envpath = tmpdir.path().to_path_buf();
    envpath.push(std::path::Path::new("grubenv"));
    (tmpdir, envpath)
  }

  #[test]
  #[serial]
  fn set () {
    let (_tmpdir, envpath) = tests_init();

    blenv::Environment::new(None, &envpath.to_string_lossy()).expect("Could not create grubenv file")
      .set("foo", "asd").expect("Could not set foo=asd in grubenv file");

    assert_eq!("# GRUB Environment Block\nfoo=asd\n", fs::read_to_string(envpath.as_path()).unwrap().as_str());
  }

  #[test]
  #[serial]
  fn get () {
    let (_tmpdir, envpath) = tests_init();
    fs::write(envpath.as_path(), "# GRUB Environment Block\nfoo=asd\n").expect("Could not write Environment file");
    let ret = blenv::Environment::new(None, &envpath.to_string_lossy()).expect("Could not create grubenv file").get("foo").expect("Could not get foo property");
    assert_eq!("asd", ret.as_str());
  }

  #[test]
  #[serial]
  fn show () {
    let (_tmpdir, envpath) = tests_init();
    fs::write(envpath.as_path(), "# GRUB Environment Block\nfoo=asd\n").expect("Could not write Environment file");
    let ret = blenv::Environment::new(None, &envpath.to_string_lossy()).expect("Could not read grubenv file");
    assert_eq!("foo=asd\n", ret.show().expect("Could not call Environment::show").as_str());
  }

  #[test]
  #[serial]
  fn invalid_header () {
    let (_tmpdir, envpath) = tests_init();
    fs::write(envpath.as_path(), "# Bogus header Block\nfoo=asd\n").expect("Could not write Environment file");
    let ret = blenv::Environment::new(None, &envpath.to_string_lossy());
    assert!(ret.is_err());
  }

  #[test]
  #[serial]
  fn clamp () {
    let (_tmpdir, envpath) = tests_init();

    for (clamp, size) in &[(None, 39), (Some(0), 39), (Some(1), 512), (Some(2), 1024)] {
      if size != &39 { // size 39 is for header and content
        let mut buffer: Vec<u8> = "# GRUB Environment Block\n".bytes().collect();
        buffer.resize(size.clone() as usize, 35 as u8); // resize with '#'<ascii code 35> for padding
        let _ = fs::write(&envpath, buffer);
      };

      let env = blenv::Environment::new(clamp.clone(), &envpath.to_string_lossy()).expect("Could not create grubenv file");
      if let Err(e) = env.set("somekey", "value") {
        panic!("Could not set key=value {:?}", e);
      };

      assert_eq!(&fs::metadata(&envpath).expect("Could not get file metadata").len(),
                 size);
    };


    let _ = fs::remove_file(&envpath);
    let env = blenv::Environment::new(Some(1), &envpath.to_string_lossy()).expect("Could not create grubenv file");
    let _ = env.set("foo", "bar");
    assert_eq!(env.get("foo").expect("Could not get 'foo' property"), "bar");
    assert_eq!(fs::metadata(&envpath).expect("Could not get grubenv metadata").len(), 512);
  }
}