POSIX基础文件IO实战:文件加密与解密

摘要: POSIX规定的一系列基础文件IO的系统调用,如read、write、lseek等,允许用户通过C语言对文件进行细粒度的操作。本文利用文件操作相关的常用API(open、read、write、lseek),通过对原文件各字节数据的重新排列和还原,实现了简单的文件加密和解密,从而为POSIX下,文件IO常用API的使用方法、命令行交互程序的设计和基于Makefile的多源文件程序管理进行举例说明。

1. 算法概述

无论是二进制文件,还是文本文件,我们都可以以字节为单位对其进行操纵。因此,只要对文件各字节的排列顺序按一定规则打乱,就能实现简单的文件加密;反之,将排列顺序还原,就能实现文件解密。

本文中,我们考虑一种简单的文件顺序改变规则:对原始文件,我们交替从文件头和文件尾向中间读取字节,再按读取顺序写入新文件,从而完成加密;对已加密文件,取奇数字节顺序排列,再取偶数字节逆序排列,即可完成解密。为练习使用Makefile管理多文件C程序,我们将程序分为以下三个源文件:

  1. main.c:负责命令行交互,和调用编码、解码函数。
  2. encrypt.c
  3. decrypt.c

其中main.c要提前对编码、解码函数进行声明。由此,我们可以撰写Makefile文件如下。

OBJECTS = main.o encrypt.o decrypt.o
crypt: ${OBJECTS}
	gcc $^ -o $@
main.o: main.c
encrypt.o: encrypt.c
decrypt.o: decrypt.c
clear:
	rm ${OBJECTS}

它同时清晰地刻画了各文件的依赖关系,便于后续的程序设计。

2. 命令行交互

2.1 含参数的main

很多bash命令的本质也是C程序。而当我们在使用bash命令的同时后缀参数时,命令参数就作为这一程序main函数的参数被传递进了程序。我们以ls命令举例,并在下文中以“ls命令参数”称呼这行命令涉及的参数,从而与main中的参数相区分。假设ls命令如下给出:

ls -a blog.md

再假设ls命令的main函数按如下方式声明:

int main(int argc, char *argv[]);  /*argv[]是以char类型指针为元素的指针数组*/

则上述ls命令向main中传入了argc和argv[]两个参数。这两个参数的具体值应如下:

argc = 3;
argv[0] = "ls";
argv[1] = "-a";
argv[2] = "blog.md"

由此可见,main的第一个参数argc是ls命令参数的个数,数组argv中每个元素,都是ls命令参数的字符串指针。这里需要注意,程序名“ls”本身也算作参数被传入了main。main传参规则可以用下面的程序进行直观的验证,这里不再赘述:

#include <stdio.h>

int main(int argc, char *argv[])
{
    printf("Parameter amount: %d\n", argc);
    int i=0;
    for(i=0; i<argc; i++)
    {   
        printf("The %dth parameter: %s\n", i, argv[i]);
    }   
    return 0;
}

2.2 write函数:向标准错误输出打印错误信息

计算机系统包含三个基本的输入输出流,即:标准输入流、标准输出流、标准错误输出流。标准输入流连接输入设备,后两者连接终端,而此三者的本质都是文件。因此,我们可以用向“标准错误输出”这个文件写入内容的方法,在遇到错误(如用户命令不合法)时,输出错误信息。举例如下:

char *error_str="crypt: invalid parameters.\n";
ssize_t condition = write(STDERR_FILENO, error_str, strlen(error_str));

可以看到,写入文件的操作是通过write函数实现的,这是定义在unistd.h中的系统调用。函数的第三个参数的数据类型是size_t,其本质是无符号长整数;返回值类型为ssize_t,意为“signed size type”,本质是有符号长整数。之所以返回值有符号,是因为当写入错误时,write会返回负值。这两个数据类型被定义在sys/types.h中。综上,虽然write在unistd.h中已被完全定义,但在实际使用中,我们往往同时包括unistd.hsys/types.h两个头文件。用来从文件读取内容的read函数与write如出一辙,读者可以在终端中键入命令man -a read查看文档,这里不再赘述。

2.3 open函数:文件的打开与新建

标准输入、标准输出和标准错误输出随系统一直处于打开状态,但其它文件则需要我们手动打开。打开文件所需的open函数定义在头文件fcntl.h中,这个头文件包含了诸多文件操作的系统调用。但需要注意的是,write、read和lseek(移动文件指针)三个API是在unistd.h中定义的,而非fcntl.h。本项目将会用到open函数的下列形式:

int open(const char *pathname, int flags, mode_t mode);

其中mode_t类型变量在创建文件时起到规定文件权限的作用,取值为一系列预定义的宏,读者可在命令行man手册中查看。

该函数的返回值被称为“文件描述符”,其本质是一个整型变量。后续的所有文件操作,如read、write,都需要在其第一个参数处指明被操作的文件,而文件描述符就是一个文件的唯一标识。在前面write的例子中,我们向第一个参数传入了宏STDERR_FILENO,它就是标准错误输出的文件描述符,在unistd.h中被定义。类似的,unistd.h中还定义了标准输出的宏STDOUT_FILENO,和标准输入的宏STDIN_FILENO。

读者可通过在终端键入man -a open查询open的其它用法和相关宏定义,这里不再赘述。

2.4 main.c

我们希望程序完成后能够以如下方式进行命令行交互:

crypt -en filename -o finalname
crypt -de filename -o finalname

我们首先应保证argc=5,其次还要求根据-en、-de和-o区分后面的文件名究竟是filename还是finalname,最后还要根据-en、-de区分是加密还是解密。因此,我们首先需要用循环语句检查命令是否合法,同时试图读取选项后的文件名;其次要将指定文件打开或新建,再根据用户选项,调用相关函数。

综上,main.c文件如下所示,这里不再过多解释:

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>

#define SUCCESS 1
#define FAIL 0
#define ENCRYPT 0
#define DECRYPT 1

void print_error()
{
    char *error_str="crypt: invalid parameters.\n";
    ssize_t condition = write(STDERR_FILENO, error_str, strlen(error_str));
}

#define ERR_QUIT        \
    do{                 \
        print_error();  \
        return -1 ;     \
    }while(0)           \

extern void encrypt(int origin_no, int obj_no);

extern void decrypt(int origin_no, int obj_no); 

int main(int argc, char *argv[])
{
    /* Ensure the command is valid */
    if(argc!=5) ERR_QUIT;
    int i=0;
    int choice_flag=0, obj_flag=0;  /*记录选项的数量,防止加密、解密选项多次出现 */
    int choice_label=0;
    char origin_dir[100], obj_dir[100];
    for(i=0; i<argc; i++)
    {
        if(strcmp(argv[i], "-en")==0 || strcmp(argv[i], "-de")==0)
        {
            if(i==argc-1) ERR_QUIT;  /*选项不允许是最后一个参数 */
            choice_flag+=1;
            strcpy(origin_dir, argv[i+1]);
            if(strcmp(argv[i], "-en")==0) choice_label=ENCRYPT;
            else choice_label=DECRYPT;
        }
        if(strcmp(argv[i], "-o")==0)
        {
            if(i==argc-1) ERR_QUIT;
            obj_flag+=1;
            strcpy(obj_dir, argv[i+1]);
        }
    }
    if(!(choice_flag==1 && obj_flag==1)) ERR_QUIT;
    if(strcmp(origin_dir, obj_dir)==0)
    {
        char *same_dir_err="crypt: origin directory and output directory cannot be the same\n";
        write(STDERR_FILENO, same_dir_err, strlen(same_dir_err));
        return -1;
    }
    /* Encrypt or decrypt */
    int origin_no=open(origin_dir, O_RDONLY);
    /* mode_t类型的宏由三部分组成:前缀S_I+功能(R、W、X等)+用户(USR、GRP、OTH等) */
    mode_t mode = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH;
    int obj_no=open(obj_dir, O_WRONLY | O_CREAT, mode);
    if(origin_no<0 || obj_no<0)
    {
        if(obj_no>=0)
        {
            close(obj_no);
            unlink(obj_dir);
        }
        char *file_err_str="crypt: file operating failed.\n";
        write(STDERR_FILENO, file_err_str, strlen(file_err_str));
        return -1;
    }
    if(choice_label==ENCRYPT) encrypt(origin_no, obj_no);
    else decrypt(origin_no, obj_no);
    close(origin_no);
    close(obj_no);
    return 0;
}

3. 加密与解密

3.1 文件指针与偏移量

“字节”是我们进行文件操作的最小单位,而我们将要操作哪个字节,是由文件指针进行确定的。譬如,当文件指针指向某一特定字节时,我们若调用read函数,就会读取此处及以后n个字节的数据(n由read的第三个参数确定,与write完全同理)。而文件指针与文件开头之间的距离,就被称之为偏移量。于是,我们需要考虑以下三个问题:

  1. 文件的开头在哪里,文件的结尾在哪里
  2. 当前文件指针的偏移量是多少
  3. 如何移动文件指针

首先我们解答后两个问题。文件指针的移动和位置的查询都可以通过lseek来进行。通过bash命令man lseek,我们可以看到lseek的原始定义:

off_t lseek(int fd, off_t offset, int whence);

该函数被定义在unistd.h中;数据类型off_t意为offset type,本质是长整数,被定义在sys/types.h中。因此调用该函数往往需要包含上述两个头文件。第二个参数offset就是文件指针目标位置相对于whence的偏移量,而whence可以从宏SEEK_CUR(当前文件指针处)、SEEK_SET(距文件开头)、SEEK_END(距文件结尾)中选取。这样,我们就总可以用下述代码查看当前文件指针相对文件开头的偏移量了:

offset = lseek(fd, 0, SEEK_CUR);

显然,将上述代码的SEEK_CUR换成SEEK_SET,将返回0,因为文件头与文件头的距离当然是0;但当我们用SEEK_END查看文件尾与文件头的距离时,事情会稍显复杂。

我们假设有文件text.txt,并用vim写入"abc"后保存退出。再编译运行下面的C程序,以打印文件每个字符对应的偏移量:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/types.h>

int main(void)
{
    int fd=open("text.txt", O_RDONLY);
    off_t end=lseek(fd, 0, SEEK_END);
    int i=0;
    for(i=0; i<=end; i++)
    {
        lseek(fd, i, SEEK_SET);
        char buffer=0;
        ssize_t truesize=read(fd, &buffer, 1);
        printf("offset=%d, buffer=%c\n", i, buffer);
    }
    return 0;
}

你将得到下列输出:

offset=0, buffer=a
offset=1, buffer=b
offset=2, buffer=c
offset=3, buffer=

offset=4, buffer=

为了更清楚地看到我们打印出的buffer究竟是什么,我们将printf中的%c换成%d,输出如下:

offset=0, buffer=97
offset=1, buffer=98
offset=2, buffer=99
offset=3, buffer=10
offset=4, buffer=0

我们发现了两个蹊跷的地方:

  1. 我们只向text.txt文件中键入了“abc”三个字符,为什么进行了5次打印?
  2. 为什么偏移量为3时会打印换行符,而偏移量为4时,buffer仍然是初值,而没有被read存入任何数据?

我们首先解释为什么offset=3时会读取到换行符。事实上,如果你再次用vim打开text.txt,会看到文件的大小是4B,而不是3B,而这多出的一字节,就是文件末尾的换行符,这是我们保存文件后自动添加的,无需我们手动输入。之所以vim要追加一个换行符,是因为POSIX标准要求文件必须以换行符结尾,用以表明文件的结束。而当我们一直用read读取字符和移动文件指针时(read在读取数据后总会自动将文件指针向后顺序移动n字节),若文件指针指向\n,且其后不再有数据,系统就会知道文件指针已达到了文件最后一个字节。当read读取完最后一个\n后,便不会再读取数据和移动指针。offset=3时的换行符就由此而来。

我们已经说明,内容为“abc”的文本文件实际包含了’a’,‘b’,‘c’,'\n’四字节的数据,那又为什么会出现offset=4、也就是文件指针指向第五个字节的情况呢?事实上,offset=4同时也是write(fb, 0, SEEK_END)的返回值,此时文件指针指向的是文件尾;而不同于文件头的是,文件尾并不是文件的最后一个字节的数据,而是最后一个字节之后一个字节的位置,这也即所谓的EOF。也就是说,“文件尾”并不是文件的实有数据,EOF也不是一个真正的“标识符”。当offset=4时,文件指针已经指向文件之外的存储空间了。结合offset=3时为换行符,read即可判别文件指针已经越过了文件的最后一个字节,于是不再从文件中读取数据。按如上推理,我们猜测offset=4时,read的返回值truesize应该是0。我们让程序在每次执行循环体时打印truesize的值:

offset=0, buffer=a, truesize=1
offset=1, buffer=b, truesize=1
offset=2, buffer=c, truesize=1
offset=3, buffer=
, truesize=1
offset=4, buffer=, truesize=0

可以看到,offset=4时,write果然没有读取任何数据。这个例子同时说明read的返回值并不总是等于我们让它读取的字节数(也即read的第三个参数),同时也给出了一种判断文件指针已达到甚至越过文件尾的方法:查看read的返回值是否为0。

3.2 encrypt.c

我们希望对待加密文件交替从两端到中央取字节,再顺序排列到新文件中,起到加密的效果。具体来说,假设原文件的内容为“abc\n”,则我们希望将其变为“a\nbc”。此外,我们前面说过,在POSIX标准中,我们希望所有文件都以换行符’\n’结尾,而按前述方法加密,文件未必以换行符结尾。因此为方便起见,无论加密后文件是否以换行符结尾,我们都追加一个换行符。因此,当原始文件内容为“abc\n”时,我们希望得到文件“a\nbc\n”;当文件为“abcd\n”时,我们希望得到“a\nbdc\n“。

在加密过程中,我们涉及到从文件首尾交替取字节的操作。于是,我们可以尝试构建两个广义的“指针”:我们并不打算定义两个真实的文件指针,而是创建两个整数来记录我们将要操作的字节所对应的偏移量。

我们已经说过,“文件头”指文件的第一个字节,“文件尾”指的却是最后一个字节之后的那个字节。为统一起见,我们将两个虚拟指针都初始化到文件外面:一个在文件头前一个字节,另一个在文件尾。

int forward_pointer=-1;
int backward_pointer=lseek(origin_no, 0, SEEK_END);

显然,整个文件的加密需要用到循环语句。循环的过程中,上述两个指针会逐渐靠近,也即backward_pointer-forward_pointer会逐渐减小。而无论文件的总字节数是奇数还是偶数,最终两指针势必相遇,即backward_pointer-forward_pointer=1。于是我们可以得到循环条件:

while(backward_pointer-forward_pointer>1)

最后,为了对前后两个指针交替操作,我们需要一个变量flag来标记当前需要操作的指针。此外,我们利用一个char变量轮流存储各字节数据。注意我们希望程序可以处理任何文件,而不限于文本文件,因此这一char类型变量存储的也未必是ASCII中的字符。我们之所以选用char,是因为char类型数据的大小正好为1字节,恰能存储任意长度为1字节的数据。

综上,加密部分的完整代码如下:

#include <unistd.h>
#include <sys/types.h>

#define BACKWARD 0
#define FORWARD 1

void encrypt(int origin_no, int obj_no)
{
    int forward_pointer=-1;
    int backward_pointer=lseek(origin_no, 0, SEEK_END);
    int flag=FORWARD;
    off_t curr_offset;
    ssize_t truesize;
    char buffer;
    while(backward_pointer-forward_pointer>1)
    {
        if(flag==FORWARD)
        {
            forward_pointer+=1;
            curr_offset=lseek(origin_no, forward_pointer, SEEK_SET);
            flag=BACKWARD;
        }
        else
        {
            backward_pointer-=1;
            curr_offset=lseek(origin_no, backward_pointer, SEEK_SET);
            flag=FORWARD;
        }
        truesize=read(origin_no, &buffer, 1);
        truesize=write(obj_no, &buffer, 1);
    }
    buffer='\n';
    lseek(obj_no, 0, SEEK_END);
    write(obj_no, &buffer, 1);
}

3.3 decrypt.c

解码(文件还原)所涉及的文件操作与前者完全相仿。

我们首先取加密后文件的所有奇数字节的数据进行顺序排列,再取所有偶数字节的数据进行逆序排列。此外,加密时我们向文件结尾处追加了一个额外的换行符,解码时需要将其忽略。

我们首先考虑对奇数字节的读取应截止于什么时候。我们设文件尾偏移量为end_offset,则end_offset-1对应我们加密后追加的换行符。因此,顺序读取应在偏移量等于end_offset-2(含)时截止。但若文件字节数为奇数,则接下来应从前面一个字节开始,隔字节逆序读取;若文件字节数为偶数,则应从后一个字节开始逆序读取。既然如此。我们不如直接根据文件长度的奇偶,求得逆序读取起始字节的绝对偏移量。不难得出,若长度为偶数字节,则起始字节就是文件追加换行符前的最后一个字节,偏移量为offset-2;若为奇数,则为offset-3。

综上,解码部分代码如下:

#include <unistd.h>
#include <sys/types.h>

void decrypt(int origin_no, int obj_no)
{
    char buffer;
    ssize_t truesize;
    off_t curr_offset;
    off_t end_offset=lseek(origin_no, 0, SEEK_END);
    lseek(origin_no, 0, SEEK_SET);
    while(lseek(origin_no, 0, SEEK_CUR)<=end_offset-2)
    {
        truesize=read(origin_no, &buffer, 1);
        lseek(origin_no, 1, SEEK_CUR);
        truesize=write(obj_no, &buffer, 1);
    }
    curr_offset=end_offset-2+1; /*减2则指向最后一个字节,再加一,就从偏移量求得了文件长度*/
    if(curr_offset%2 != 0) curr_offset=lseek(origin_no, -3, SEEK_END);
    else curr_offset=lseek(origin_no, -2, SEEK_END);
    while(curr_offset>=1)
    {
        truesize=read(origin_no, &buffer, 1);
        curr_offset=lseek(origin_no, -3, SEEK_CUR);
        truesize=write(obj_no, &buffer, 1);
    }
}

至此,我们已完成全部源代码的撰写。在当前文件夹打开终端,并键入命令make,即可得到可执行文件crypt。若想将我们的加密程序crypt作为一个bash命令,可在~/.bashrc中追加下面这行代码:

alias crypt="可执行文件所在目录"

这实则是用crypt作为了我们的程序绝对目录的别名。我们的程序于是可以按预设方式工作。

  • 11
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

张向南zhangxn

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值